diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 630ce93b0..5e55ffd32 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -297,8 +297,19 @@ extension Vminitd: VirtualMachineAgent { $0.containerID = containerID } } - let resp = try await client.startProcess(request) - return resp.pid + do { + let resp = try await client.startProcess(request) + return resp.pid + } catch let error as RPCError { + switch error.code { + case .notFound: + throw ContainerizationError(.notFound, message: error.message, cause: error) + case .permissionDenied: + throw ContainerizationError(.notExecutable, message: error.message, cause: error) + default: + throw error + } + } } public func signalProcess(id: String, containerID: String?, signal: Int32) async throws { diff --git a/Sources/ContainerizationError/ContainerizationError.swift b/Sources/ContainerizationError/ContainerizationError.swift index 2d1b36fc8..b907fb9d0 100644 --- a/Sources/ContainerizationError/ContainerizationError.swift +++ b/Sources/ContainerizationError/ContainerizationError.swift @@ -104,6 +104,7 @@ extension ContainerizationError { case timeout case unsupported case interrupted + case notExecutable } private var value: Value @@ -166,6 +167,10 @@ extension ContainerizationError { public static var interrupted: Self { Self(.interrupted) } + + public static var notExecutable: Self { + Self(.notExecutable) + } } } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 150db50c8..5336e66f3 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -191,7 +191,36 @@ extension IntegrationSuite { } catch { try? await container.stop() guard let err = error as? ContainerizationError, - err.isCode(.internalError), err.description.contains("failed to find target executable") + err.isCode(.notFound), err.description.contains("failed to find target executable") + else { + throw error + } + } + } + + func testProcessNotExecutable() async throws { + let id = "test-process-not-executable" + let bs = try await bootstrap(id) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/etc/passwd"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let _ = try await container.wait() + try await container.stop() + + throw IntegrationError.assert(msg: "process didn't throw 'not executable' error") + } catch { + try? await container.stop() + guard let err = error as? ContainerizationError, + err.isCode(.notExecutable), err.description.contains("failed to exec") else { throw error } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 6ce16125d..6ab368877 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -483,6 +483,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("process false", testProcessFalse), Test("process echo hi", testProcessEchoHi), Test("process no executable", testProcessNoExecutable), + Test("process not executable", testProcessNotExecutable), Test("process user", testProcessUser), Test("process stdin", testProcessStdin), Test("process home envvar", testProcessHomeEnvvar), diff --git a/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift b/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift new file mode 100644 index 000000000..9b1d8a1c4 --- /dev/null +++ b/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) + +import ContainerizationError +import Testing + +@testable import VminitdCore + +@Suite("ManagedProcess vmexec error") +struct ManagedProcessErrorTests { + @Test func missingExecutableKeepsNotFound() { + let error = ManagedProcess.vmexecError( + ContainerizationError(.notFound, message: "failed to find target executable foo").description + ) + #expect(error.code == .notFound) + #expect(error.message.contains("failed to find target executable foo")) + } + + @Test func notExecutableKeepsCode() { + let error = ManagedProcess.vmexecError( + ContainerizationError(.notExecutable, message: "failed to exec [/etc/passwd]").description + "\n" + ) + #expect(error.code == .notExecutable) + } + + @Test(arguments: [ + ContainerizationError(.internalError, message: "failed to change directory").description, + ContainerizationError(.invalidArgument, message: "bad").description, + "unstructured failure", + ]) + func otherErrorsAreInternal(errorString: String) { + #expect(ManagedProcess.vmexecError(errorString).code == .internalError) + } +} + +#endif diff --git a/vminitd/Sources/VminitdCore/ManagedProcess.swift b/vminitd/Sources/VminitdCore/ManagedProcess.swift index ba4cd2d1e..eead9d3c3 100644 --- a/vminitd/Sources/VminitdCore/ManagedProcess.swift +++ b/vminitd/Sources/VminitdCore/ManagedProcess.swift @@ -239,10 +239,7 @@ extension ManagedProcess { let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - throw ContainerizationError( - .internalError, - message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))" - ) + throw Self.vmexecError(errorString) } log.info( @@ -259,16 +256,21 @@ extension ManagedProcess { let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - throw ContainerizationError( - .internalError, - message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))", - cause: error - ) + throw Self.vmexecError(errorString, cause: error) } throw error } } + /// vmexec writes `ContainerizationError.description` to the error pipe, so + /// the text starts with the error code. + static func vmexecError(_ errorString: String, cause: (any Error)? = nil) -> ContainerizationError { + let text = errorString.trimmingCharacters(in: .whitespacesAndNewlines) + let preserved: [ContainerizationError.Code] = [.notFound, .notExecutable] + let code = preserved.first { text.hasPrefix("\($0): ") } ?? .internalError + return ContainerizationError(code, message: "vmexec error: \(text)", cause: cause) + } + func setExit(_ status: Int32) { self.state.withLock { state in self.log.info( diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 7f20185ac..1e8f80a91 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -79,6 +79,8 @@ extension ContainerizationError { return .failedPrecondition case .timeout: return .deadlineExceeded + case .notExecutable: + return .permissionDenied default: return .internalError } diff --git a/vminitd/Sources/vmexec/vmexec.swift b/vminitd/Sources/vmexec/vmexec.swift index 956680cb8..84741b23b 100644 --- a/vminitd/Sources/vmexec/vmexec.swift +++ b/vminitd/Sources/vmexec/vmexec.swift @@ -85,12 +85,12 @@ extension App { } guard FileManager.default.fileExists(atPath: resolvedExecutable.path) else { - throw App.Failure(message: "failed to find target executable \(executableArg)") + throw ContainerizationError(.notFound, message: "failed to find target executable \(executableArg)") } } else { let path = Path.findPath(currentEnv) ?? Path.getCurrentPath() guard let found = Path.lookPath(executableArg, path: path) else { - throw App.Failure(message: "failed to find target executable \(executableArg)") + throw ContainerizationError(.notFound, message: "failed to find target executable \(executableArg)") } resolvedExecutable = found } @@ -116,7 +116,20 @@ extension App { } guard execvpe(executable, argv, env) != -1 else { - throw App.Errno(stage: "execvpe(\(String(describing: executable)))", info: "failed to exec [\(process.args.joined(separator: " "))]") + let code: ContainerizationError.Code + switch errno { + case ENOENT: + code = .notFound + case EACCES, ENOEXEC: + code = .notExecutable + default: + code = .internalError + } + throw App.Errno( + code: code, + stage: "execvpe(\(String(describing: executable)))", + info: "failed to exec [\(process.args.joined(separator: " "))]" + ) } fatalError("execvpe failed") } @@ -254,9 +267,9 @@ extension App { } } - static func Errno(stage: String, info: String = "") -> ContainerizationError { + static func Errno(code: ContainerizationError.Code = .internalError, stage: String, info: String = "") -> ContainerizationError { let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage]) - return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))") + return ContainerizationError(code, message: "\(info) \(String(describing: posix))") } static func Failure(message: String) -> ContainerizationError {