diff --git a/Package.swift b/Package.swift index 63f81a6b..446a79ff 100644 --- a/Package.swift +++ b/Package.swift @@ -323,6 +323,11 @@ let package = Package( ], path: "vminitd/Sources/VminitdCore" ), + .testTarget( + name: "VminitdCoreTests", + dependencies: ["VminitdCore"], + path: "Tests/VminitdCoreTests" + ), ] ) diff --git a/Sources/Containerization/FilesystemOperationPath.swift b/Sources/Containerization/FilesystemOperationPath.swift new file mode 100644 index 00000000..a694d679 --- /dev/null +++ b/Sources/Containerization/FilesystemOperationPath.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import Foundation + +package enum FilesystemOperationPath { + package static func validate(_ path: String) throws { + guard path.first == "/", !path.contains("\0") else { + throw invalidPath(path) + } + guard path == "/" || (!path.hasSuffix("/") && !path.contains("//")) else { + throw invalidPath(path) + } + guard !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { + throw invalidPath(path) + } + } + + private static func invalidPath(_ path: String) -> ContainerizationError { + ContainerizationError( + .invalidArgument, + message: "filesystem operation path must be an absolute canonical container path: \(path.debugDescription)" + ) + } +} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index e24bdf67..bf00ac4a 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -1134,8 +1134,11 @@ extension LinuxContainer { guard let vminitd = agent as? Vminitd else { throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") } - let guestPath = URL(filePath: Self.guestRootfsPath(self.id)).appending(path: path).path - try await vminitd.filesystemOperation(operation: operation, path: guestPath) + try await vminitd.filesystemOperation( + operation: operation, + path: path, + containerID: self.id + ) } } } diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 6275a4d4..11e2728e 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -1262,8 +1262,19 @@ extension LinuxPod { return try await fn(vm) } + struct FilesystemOperationTarget: Equatable { + let containerID: String + let path: String + } + + static func filesystemOperationTarget(containerID: String, path: String) throws -> FilesystemOperationTarget { + try FilesystemOperationPath.validate(path) + return FilesystemOperationTarget(containerID: containerID, path: path) + } + // Perform filesystem operations in a container. public func filesystemOperation(_ containerID: String, operation: FilesystemOperation, path: String) async throws { + let target = try Self.filesystemOperationTarget(containerID: containerID, path: path) try await self.state.withLock { state in let createdState = try state.phase.createdState("filesystemOperation") @@ -1285,8 +1296,11 @@ extension LinuxPod { guard let vminitd = agent as? Vminitd else { throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") } - let guestPath = URL(filePath: Self.guestRootfsPath(containerID)).appending(path: path).path - try await vminitd.filesystemOperation(operation: operation, path: guestPath) + try await vminitd.filesystemOperation( + operation: operation, + path: target.path, + containerID: target.containerID + ) } } } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index a091d8b2..10225131 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1154,6 +1154,16 @@ public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperat set {operation = .thaw(newValue)} } + /// Resolve path from this container's init process mount namespace. + public var containerID: String { + get {_containerID ?? String()} + set {_containerID = newValue} + } + /// Returns true if `containerID` has been explicitly set. + public var hasContainerID: Bool {self._containerID != nil} + /// Clears the value of `containerID`. Subsequent reads from it will return its default value. + public mutating func clearContainerID() {self._containerID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public nonisolated enum OneOf_Operation: Equatable, Sendable { @@ -1164,6 +1174,8 @@ public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperat } public init() {} + + fileprivate var _containerID: String? = nil } public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse: Sendable { @@ -3453,7 +3465,7 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiTrimResult: SwiftP nonisolated extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".FilesystemOperationRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}trim\0\u{1}freeze\0\u{1}thaw\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}trim\0\u{1}freeze\0\u{1}thaw\0\u{3}container_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3501,6 +3513,7 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationR self.operation = .thaw(v) } }() + case 5: try { try decoder.decodeSingularStringField(value: &self._containerID) }() default: break } } @@ -3529,12 +3542,16 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationR }() case nil: break } + try { if let v = self._containerID { + try visitor.visitSingularStringField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, rhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest) -> Bool { if lhs.path != rhs.path {return false} if lhs.operation != rhs.operation {return false} + if lhs._containerID != rhs._containerID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 24fb2425..06f11211 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -315,6 +315,8 @@ message FilesystemOperationRequest { FiFreezeParams freeze = 3; FiThawParams thaw = 4; } + // Resolve path from this container's init process mount namespace. + optional string container_id = 5; } message FilesystemOperationResponse { diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 6af76662..9859e18f 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -214,11 +214,35 @@ extension Vminitd: VirtualMachineAgent { /// Perform a filesystem operation on a path inside the sandbox's environment. public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + try await filesystemOperation(operation: operation, path: path, containerID: nil) + } + + public func filesystemOperation( + operation: FilesystemOperation, + path: String, + containerID: String? + ) async throws { _ = try await client.filesystemOperation( - .with { - $0.operation = operation.toProtoOperation() - $0.path = path - }) + try Self.filesystemOperationRequest( + operation: operation, + path: path, + containerID: containerID + )) + } + + package static func filesystemOperationRequest( + operation: FilesystemOperation, + path: String, + containerID: String? + ) throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest { + try FilesystemOperationPath.validate(path) + return .with { + $0.operation = operation.toProtoOperation() + $0.path = path + if let containerID { + $0.containerID = containerID + } + } } public func createProcess( diff --git a/Tests/ContainerizationTests/LinuxContainerTests.swift b/Tests/ContainerizationTests/LinuxContainerTests.swift index 713e3982..7ff20587 100644 --- a/Tests/ContainerizationTests/LinuxContainerTests.swift +++ b/Tests/ContainerizationTests/LinuxContainerTests.swift @@ -14,8 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationError import ContainerizationOCI -import ContainerizationOS import Foundation import Testing @@ -119,4 +119,47 @@ struct LinuxContainerTests { #expect(pod.maskedPaths == expectedMasked) #expect(pod.readonlyPaths == expectedReadonly) } + + @Test func filesystemOperationPathAcceptsContainerAbsolutePaths() throws { + try FilesystemOperationPath.validate("/") + try FilesystemOperationPath.validate("/mnt/reclaim-data") + } + + @Test( + arguments: [ + "", + "mnt/reclaim-data", + "/mnt/../etc", + "/mnt/./reclaim-data", + "/mnt//reclaim-data", + "/mnt/reclaim-data/", + "/mnt/\0escape", + ]) + func filesystemOperationPathRejectsAmbiguousOrEscapingPaths(path: String) { + #expect(throws: ContainerizationError.self) { + try FilesystemOperationPath.validate(path) + } + } + + @Test func podFilesystemOperationTargetsContainerMountNamespace() throws { + let target = try LinuxPod.filesystemOperationTarget( + containerID: "pod-clean-volume", + path: "/mnt/reclaim-data" + ) + + #expect(target.path == "/mnt/reclaim-data") + #expect(target.containerID == "pod-clean-volume") + } + + @Test func filesystemOperationRequestTargetsContainerMountNamespace() throws { + let request = try Vminitd.filesystemOperationRequest( + operation: .trim, + path: "/mnt/reclaim-data", + containerID: "clean-volume-reclaim" + ) + + #expect(request.path == "/mnt/reclaim-data") + #expect(request.containerID == "clean-volume-reclaim") + #expect(request.hasContainerID) + } } diff --git a/Tests/VminitdCoreTests/FilesystemOperationTargetTests.swift b/Tests/VminitdCoreTests/FilesystemOperationTargetTests.swift new file mode 100644 index 00000000..d506366c --- /dev/null +++ b/Tests/VminitdCoreTests/FilesystemOperationTargetTests.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Glibc +import Testing + +@testable import VminitdCore + +private let mountNamespaceTestEnabled = + ProcessInfo.processInfo.environment["CONTAINERIZATION_TEST_MOUNT_NAMESPACE"] == "1" + +struct FilesystemOperationTargetTests { + @Test func opensPathRelativeToContainerRoot() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let target = root.appending(path: "mnt/reclaim-data") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let rootDescriptor = Glibc.open(root.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + #expect(rootDescriptor >= 0) + defer { _ = Glibc.close(rootDescriptor) } + + let targetDescriptor = try FilesystemOperationTarget.open( + rootDescriptor: rootDescriptor, + path: "/mnt/reclaim-data" + ) + defer { _ = Glibc.close(targetDescriptor) } + + var expected = Glibc.stat() + var actual = Glibc.stat() + #expect(Glibc.lstat(target.path, &expected) == 0) + #expect(Glibc.fstat(targetDescriptor, &actual) == 0) + #expect(actual.st_dev == expected.st_dev) + #expect(actual.st_ino == expected.st_ino) + } + + @Test func containsAbsoluteSymlinksWithinContainerRoot() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + #expect(Glibc.symlink("/", root.appending(path: "escape").path) == 0) + + let rootDescriptor = Glibc.open(root.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + #expect(rootDescriptor >= 0) + defer { _ = Glibc.close(rootDescriptor) } + + #expect(throws: POSIXError.self) { + _ = try FilesystemOperationTarget.open( + rootDescriptor: rootDescriptor, + path: "/escape/etc" + ) + } + } + + @Test func rejectsProcMagicLinks() throws { + let rootDescriptor = Glibc.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + #expect(rootDescriptor >= 0) + defer { _ = Glibc.close(rootDescriptor) } + + #expect(throws: POSIXError.self) { + _ = try FilesystemOperationTarget.open( + rootDescriptor: rootDescriptor, + path: "/proc/self/root" + ) + } + } +} + +struct ProcessRootTests { + @Test(.enabled(if: mountNamespaceTestEnabled)) + func opensMountFromTargetProcessNamespace() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let source = root.appending(path: "source") + let target = root.appending(path: "target") + let ready = root.appending(path: "ready") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/unshare") + process.arguments = [ + "--mount", + "--", + "/bin/sh", + "-c", + "mount --make-rprivate / && mount --bind \"$1\" \"$2\" && touch \"$3\" && exec sleep 5", + "mount-namespace-test", + source.path, + target.path, + ready.path, + ] + try process.run() + defer { + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + } + + let deadline = Date.now.addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: ready.path), Date.now < deadline { + usleep(10_000) + } + try #require(FileManager.default.fileExists(atPath: ready.path)) + + let processRoot = try ProcessRoot(processID: process.processIdentifier) + let rootDescriptor = try processRoot.openRoot() + defer { _ = Glibc.close(rootDescriptor) } + let targetDescriptor = try FilesystemOperationTarget.open( + rootDescriptor: rootDescriptor, + path: target.path + ) + defer { _ = Glibc.close(targetDescriptor) } + + var sourceInfo = Glibc.stat() + var parentTargetInfo = Glibc.stat() + var namespacedTargetInfo = Glibc.stat() + #expect(Glibc.lstat(source.path, &sourceInfo) == 0) + #expect(Glibc.lstat(target.path, &parentTargetInfo) == 0) + #expect(Glibc.fstat(targetDescriptor, &namespacedTargetInfo) == 0) + #expect(namespacedTargetInfo.st_dev == sourceInfo.st_dev) + #expect(namespacedTargetInfo.st_ino == sourceInfo.st_ino) + #expect(namespacedTargetInfo.st_ino != parentTargetInfo.st_ino) + } + + @Test func opensRunningProcessRoot() throws { + let process = Process() + process.executableURL = URL(filePath: "/bin/sleep") + process.arguments = ["1"] + try process.run() + defer { + process.terminate() + process.waitUntilExit() + } + + let processRoot = try ProcessRoot(processID: process.processIdentifier) + let descriptor = try processRoot.openRoot() + defer { _ = Glibc.close(descriptor) } + + var expected = Glibc.stat() + var actual = Glibc.stat() + #expect(Glibc.lstat("/", &expected) == 0) + #expect(Glibc.fstat(descriptor, &actual) == 0) + #expect(actual.st_dev == expected.st_dev) + #expect(actual.st_ino == expected.st_ino) + } + + @Test func doesNotRetargetAfterProcessExit() throws { + let process = Process() + process.executableURL = URL(filePath: "/bin/sleep") + process.arguments = ["1"] + try process.run() + + let processRoot = try ProcessRoot(processID: process.processIdentifier) + process.terminate() + process.waitUntilExit() + + #expect(throws: POSIXError.self) { + _ = try processRoot.openRoot() + } + } +} + +#endif diff --git a/vminitd/Sources/LCShim/include/syscall.h b/vminitd/Sources/LCShim/include/syscall.h index 815dd476..c46cf87e 100644 --- a/vminitd/Sources/LCShim/include/syscall.h +++ b/vminitd/Sources/LCShim/include/syscall.h @@ -99,6 +99,8 @@ int CZ_pidfd_open(pid_t pid, unsigned int flags); #endif int CZ_pidfd_getfd(int pidfd, int targetfd, unsigned int flags); +int CZ_openat2_in_root(int dirfd, const char *path, int flags); + int CZ_prctl_set_no_new_privs(); #endif diff --git a/vminitd/Sources/LCShim/syscall.c b/vminitd/Sources/LCShim/syscall.c index 094f6c61..6faca905 100644 --- a/vminitd/Sources/LCShim/syscall.c +++ b/vminitd/Sources/LCShim/syscall.c @@ -22,6 +22,19 @@ #include "syscall.h" +#ifndef SYS_openat2 +#define SYS_openat2 437 +#endif + +#define CZ_RESOLVE_NO_MAGICLINKS 0x02 +#define CZ_RESOLVE_IN_ROOT 0x10 + +struct cz_open_how { + unsigned long long flags; + unsigned long long mode; + unsigned long long resolve; +}; + int CZ_pivot_root(const char *new_root, const char *put_old) { return syscall(SYS_pivot_root, new_root, put_old); } @@ -38,6 +51,14 @@ int CZ_pidfd_getfd(int pidfd, int targetfd, unsigned int flags) { return syscall(SYS_pidfd_getfd, pidfd, targetfd, flags); } +int CZ_openat2_in_root(int dirfd, const char *path, int flags) { + struct cz_open_how how = { + .flags = flags, + .resolve = CZ_RESOLVE_IN_ROOT | CZ_RESOLVE_NO_MAGICLINKS, + }; + return syscall(SYS_openat2, dirfd, path, &how, sizeof(how)); +} + int CZ_prctl_set_no_new_privs() { return prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); } diff --git a/vminitd/Sources/VminitdCore/ContainerProcess.swift b/vminitd/Sources/VminitdCore/ContainerProcess.swift index e9fe82be..76aebbab 100644 --- a/vminitd/Sources/VminitdCore/ContainerProcess.swift +++ b/vminitd/Sources/VminitdCore/ContainerProcess.swift @@ -36,6 +36,9 @@ protocol ContainerProcess: Sendable { /// Process ID of the running container (nil if not started) var pid: Int32? { get } + /// Open the process root without resolving its numeric PID again. + func openRoot() throws -> Int32 + /// Start the container process /// - Returns: The process ID of the started container /// - Throws: If the process fails to start diff --git a/vminitd/Sources/VminitdCore/FilesystemOperationTarget.swift b/vminitd/Sources/VminitdCore/FilesystemOperationTarget.swift new file mode 100644 index 00000000..5814e1eb --- /dev/null +++ b/vminitd/Sources/VminitdCore/FilesystemOperationTarget.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import Foundation +import LCShim + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +enum FilesystemOperationTarget { + static func open(rootDescriptor: Int32, path: String) throws -> Int32 { + try FilesystemOperationPath.validate(path) + + let flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + for _ in 0..<3 { + let descriptor = path.withCString { + CZ_openat2_in_root(rootDescriptor, $0, flags) + } + if descriptor >= 0 { + return descriptor + } + guard errno == EAGAIN else { + throw POSIXError.fromErrno() + } + } + throw POSIXError(.EAGAIN) + } +} + +#endif diff --git a/vminitd/Sources/VminitdCore/ManagedContainer.swift b/vminitd/Sources/VminitdCore/ManagedContainer.swift index 545046a8..7fe6cae9 100644 --- a/vminitd/Sources/VminitdCore/ManagedContainer.swift +++ b/vminitd/Sources/VminitdCore/ManagedContainer.swift @@ -236,6 +236,10 @@ extension ManagedContainer { try self.cgroupManager.getMemoryEvents() } + func openRoot() throws -> Int32 { + try self.initProcess.openRoot() + } + func getExecOrInit(execID: String) throws -> any ContainerProcess { if execID == self.id { return self.initProcess diff --git a/vminitd/Sources/VminitdCore/ManagedProcess.swift b/vminitd/Sources/VminitdCore/ManagedProcess.swift index ba4cd2d1..985c6040 100644 --- a/vminitd/Sources/VminitdCore/ManagedProcess.swift +++ b/vminitd/Sources/VminitdCore/ManagedProcess.swift @@ -46,6 +46,7 @@ final class ManagedProcess: ContainerProcess, Sendable { var waiters: [CheckedContinuation] = [] var exitStatus: ContainerExitStatus? = nil var pid: Int32? + var processRoot: ProcessRoot? } private static let ackPid = "AckPid" @@ -191,6 +192,8 @@ extension ManagedProcess { metadata: [ "pid": "\(pid)" ]) + let processRoot = try ProcessRoot(processID: pid) + $0.processRoot = processRoot $0.pid = pid // This should probably happen in vmexec, but we don't need to set any cgroup @@ -279,6 +282,8 @@ extension ManagedProcess { let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now) state.exitStatus = exitStatus + state.pid = nil + state.processRoot = nil do { try state.io.close() @@ -325,6 +330,15 @@ extension ManagedProcess { } } + func openRoot() throws -> Int32 { + try self.state.withLock { + guard $0.exitStatus == nil, let processRoot = $0.processRoot else { + throw ContainerizationError(.invalidState, message: "process is not running") + } + return try processRoot.openRoot() + } + } + func resize(size: Terminal.Size) throws { try self.state.withLock { guard $0.exitStatus == nil else { diff --git a/vminitd/Sources/VminitdCore/ProcessRoot.swift b/vminitd/Sources/VminitdCore/ProcessRoot.swift new file mode 100644 index 00000000..35da57cf --- /dev/null +++ b/vminitd/Sources/VminitdCore/ProcessRoot.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +final class ProcessRoot: @unchecked Sendable { + private let processDescriptor: Int32 + + init(processID: Int32) throws { + let descriptor = Foundation.open( + "/proc/\(processID)", + O_RDONLY | O_DIRECTORY | O_CLOEXEC + ) + guard descriptor >= 0 else { + throw POSIXError.fromErrno() + } + self.processDescriptor = descriptor + } + + deinit { + _ = Foundation.close(processDescriptor) + } + + func openRoot() throws -> Int32 { + let descriptor = Foundation.openat( + processDescriptor, + "root", + O_RDONLY | O_DIRECTORY | O_CLOEXEC + ) + guard descriptor >= 0 else { + throw POSIXError.fromErrno() + } + return descriptor + } +} + +#endif diff --git a/vminitd/Sources/VminitdCore/RuncProcess.swift b/vminitd/Sources/VminitdCore/RuncProcess.swift index eae35f81..0ab93734 100644 --- a/vminitd/Sources/VminitdCore/RuncProcess.swift +++ b/vminitd/Sources/VminitdCore/RuncProcess.swift @@ -48,6 +48,7 @@ final class RuncProcess: ContainerProcess, Sendable { private struct State { var state: ProcessState = .initial var waiters: [CheckedContinuation] = [] + var processRoot: ProcessRoot? } let id: String @@ -185,10 +186,12 @@ final class RuncProcess: ContainerProcess, Sendable { try self.io.closeAfterExec() } + let processRoot = try ProcessRoot(processID: pid) try await self.runc.start(id: self.id) self.state.withLock { $0.state = .running(pid: pid) + $0.processRoot = processRoot } self.log.info( @@ -211,6 +214,7 @@ final class RuncProcess: ContainerProcess, Sendable { let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now) $0.state = .exited(exitStatus) + $0.processRoot = nil do { try self.io.close() @@ -244,6 +248,15 @@ final class RuncProcess: ContainerProcess, Sendable { try await self.runc.kill(id: self.id, signal: signal) } + func openRoot() throws -> Int32 { + try self.state.withLock { + guard case .running = $0.state, let processRoot = $0.processRoot else { + throw ContainerizationError(.invalidState, message: "process is not running") + } + return try processRoot.openRoot() + } + } + func resize(size: Terminal.Size) throws { try self.state.withLock { if case .exited = $0.state { diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index dd07ef54..8b0b2dc6 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -728,26 +728,48 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ metadata: [ "operation": "\(String(describing: request.operation))", "path": "\(path)", + "containerID": "\(request.hasContainerID ? request.containerID : "")", ]) - if !path.isAbsolute { - throw RPCError(code: .invalidArgument, message: "path must be absolute") - } + let fd: Int32 + if request.hasContainerID { + do { + let container = try await self.state.get(container: request.containerID) + let rootDescriptor = try await container.openRoot() + defer { close(rootDescriptor) } + fd = try FilesystemOperationTarget.open( + rootDescriptor: rootDescriptor, + path: request.path + ) + } catch let error as ContainerizationError { + throw error.toRPCError(operation: "filesystemOperation") + } catch { + throw RPCError( + code: .internalError, + message: "failed to open container filesystem path", + cause: error + ) + } + } else { + if !path.isAbsolute { + throw RPCError(code: .invalidArgument, message: "path must be absolute") + } - var finfo = _stat_struct() - let rc = _stat(path.string, &finfo) - if rc != 0 { - let error = swiftErrno("stat") - throw RPCError(code: .notFound, message: "failed to stat path", cause: error) - } + var finfo = _stat_struct() + let rc = _stat(path.string, &finfo) + if rc != 0 { + let error = swiftErrno("stat") + throw RPCError(code: .notFound, message: "failed to stat path", cause: error) + } - let fd = open(path.string, O_RDONLY | O_NOFOLLOW) - if fd < 0 { - if errno == ELOOP { - throw RPCError(code: .internalError, message: "path cannot be a symlink") + fd = open(path.string, O_RDONLY | O_NOFOLLOW) + if fd < 0 { + if errno == ELOOP { + throw RPCError(code: .internalError, message: "path cannot be a symlink") + } + let error = swiftErrno("open") + throw RPCError(code: .internalError, message: "failed to open path", cause: error) } - let error = swiftErrno("open") - throw RPCError(code: .internalError, message: "failed to open path", cause: error) } defer { close(fd) }