From 390add40bfbbe0656816dd28139ad781a7e7a7b5 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 30 Apr 2026 18:22:46 +0200 Subject: [PATCH 1/3] Add copyIn to resolve destination paths using Stat from vminitd --- Sources/Containerization/LinuxContainer.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index c51cb622..f3309451 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -1065,7 +1065,21 @@ extension LinuxContainer { } let isArchive = isDirectory.boolValue - let guestPath = URL(filePath: self.root).appending(path: destination.path) + let resolvedDestination: URL = try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent") + } + do { + let stat = try await vminitd.stat(path: destination) + let isDir = (stat.mode & 0o170000) == 0o040000 + if isDir { + return destination.appendingPathComponent(source.lastPathComponent) + } + } catch { } + return destination + } + + let guestPath = URL(filePath: self.root).appending(path: resolvedDestination.path) let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue let listener = try state.vm.listen(port) From 45b8ee5e83f690abb5bbafa7fb4adb4746428c7e Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 6 May 2026 06:28:43 +0200 Subject: [PATCH 2/3] Add resolveCopyInGuestPath for CopyIn --- Sources/Containerization/LinuxContainer.swift | 61 +++++++++++++++---- Sources/Containerization/Vminitd.swift | 7 ++- vminitd/Sources/VminitdCore/Server+GRPC.swift | 7 +++ 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index f3309451..a7f3607b 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -18,6 +18,7 @@ import ContainerizationArchive import ContainerizationError import ContainerizationExtras import ContainerizationOCI +import ContainerizationOS import Foundation import Logging import Synchronization @@ -1065,21 +1066,19 @@ extension LinuxContainer { } let isArchive = isDirectory.boolValue - let resolvedDestination: URL = try await state.vm.withAgent { agent in + let guestPath: URL = try await state.vm.withAgent { agent in guard let vminitd = agent as? Vminitd else { throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent") } - do { - let stat = try await vminitd.stat(path: destination) - let isDir = (stat.mode & 0o170000) == 0o040000 - if isDir { - return destination.appendingPathComponent(source.lastPathComponent) - } - } catch { } - return destination + + return try await self.resolveCopyInGuestPath( + from: source, + to: destination, + sourceIsDirectory: isArchive, + using: vminitd + ) } - - let guestPath = URL(filePath: self.root).appending(path: resolvedDestination.path) + let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue let listener = try state.vm.listen(port) @@ -1164,6 +1163,46 @@ extension LinuxContainer { } } + private func resolveCopyInGuestPath( + from source: URL, + to destination: URL, + sourceIsDirectory: Bool, + using vminitd: Vminitd + ) async throws -> URL { + let guestDestination = URL(filePath: self.root).appending(path: destination.path) + + let stat: ContainerizationOS.Stat? + do { + stat = try await vminitd.stat(path: guestDestination) + } catch let error as ContainerizationError where error.code == .notFound { + stat = nil + } + // Any other error propagates so transport and permission failures are visible. + + guard let stat else { + if destination.hasDirectoryPath && !sourceIsDirectory { + throw ContainerizationError( + .invalidArgument, + message: "destination directory does not exist: \(destination.path)" + ) + } + return guestDestination + } + + let destinationIsDirectory = (stat.mode & UInt32(S_IFMT)) == UInt32(S_IFDIR) + guard destinationIsDirectory else { + if sourceIsDirectory { + throw ContainerizationError( + .invalidArgument, + message: "cannot copy directory over existing file: \(destination.path)" + ) + } + return guestDestination + } + + return guestDestination.appendingPathComponent(source.lastPathComponent) + } + /// Copy a file or directory from the container to the host. /// /// Data transfer happens over a dedicated vsock connection. For directories, diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index bf62a034..d23eff53 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -472,7 +472,12 @@ extension Vminitd { $0.path = path.path } - let response = try await client.stat(request) + let response: Com_Apple_Containerization_Sandbox_V3_StatResponse + do { + response = try await client.stat(request) + } catch let error as RPCError where error.code == .notFound { + throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error) + } guard response.error.isEmpty else { throw ContainerizationError(.internalError, message: "stat: \(response.error)") } diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 23cca9f8..c035623d 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -384,6 +384,13 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ let result = _stat(request.path, &s) if result == -1 { let error = swiftErrno("stat") + if error.code == .ENOENT { + throw RPCError( + code: .notFound, + message: "stat: path not found '\(request.path)'", + cause: error + ) + } return .with { $0.error = "\(error)" } } return .with { From 70fca4ce4e30200ffca5c8235903b84ad5f6d9d2 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 7 May 2026 09:01:55 +0200 Subject: [PATCH 3/3] Add CopyIn tests for destination path resolution --- Sources/Integration/ContainerTests.swift | 150 +++++++++++++++++++++++ Sources/Integration/Suite.swift | 3 + 2 files changed, 153 insertions(+) diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 38bb5321..6ad88d7b 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -1784,6 +1784,156 @@ extension IntegrationSuite { } } + func testCopyInFileToExistingDirectory() async throws { + let id = "test-copy-in-file-to-dir" + + let bs = try await bootstrap(id) + + let testContent = "copy into an existing guest directory" + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("host-file.txt") + try testContent.write(to: hostFile, atomically: true, encoding: .utf8) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let mkdir = try await container.exec("create-copy-target") { config in + config.arguments = ["mkdir", "-p", "/tmp/copy-target"] + } + try await mkdir.start() + let mkdirStatus = try await mkdir.wait() + try await mkdir.delete() + + guard mkdirStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "mkdir failed with status \(mkdirStatus)") + } + + try await container.copyIn( + from: hostFile, + to: URL(filePath: "/tmp/copy-target") + ) + + let buffer = BufferWriter() + let verify = try await container.exec("verify-copy-target") { config in + config.arguments = ["cat", "/tmp/copy-target/host-file.txt"] + config.stdout = buffer + } + try await verify.start() + let verifyStatus = try await verify.wait() + try await verify.delete() + + guard verifyStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "cat copied file failed with status \(verifyStatus)") + } + guard String(data: buffer.data, encoding: .utf8) == testContent else { + throw IntegrationError.assert(msg: "copied file should land under the existing destination directory") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyInFileToMissingDirectoryFails() async throws { + let id = "test-copy-in-file-missing-dir" + + let bs = try await bootstrap(id) + + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("host-file.txt") + try "missing destination directory".write(to: hostFile, atomically: true, encoding: .utf8) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + do { + try await container.copyIn( + from: hostFile, + to: URL(filePath: "/tmp/missing-copy-target/") + ) + throw IntegrationError.assert(msg: "copyIn should fail when copying a file to a missing destination directory") + } catch let error as ContainerizationError where error.code == .invalidArgument { + guard error.description.contains("destination directory does not exist") else { + throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)") + } + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyInDirectoryOverExistingFileFails() async throws { + let id = "test-copy-in-dir-over-file" + + let bs = try await bootstrap(id) + + let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("host-dir") + try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true) + try "directory content".write(to: hostDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let createFile = try await container.exec("create-existing-file") { config in + config.arguments = ["sh", "-c", "echo -n existing > /tmp/existing-file"] + } + try await createFile.start() + let createStatus = try await createFile.wait() + try await createFile.delete() + + guard createStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "failed to create existing file, status \(createStatus)") + } + + do { + try await container.copyIn( + from: hostDir, + to: URL(filePath: "/tmp/existing-file") + ) + throw IntegrationError.assert(msg: "copyIn should fail when copying a directory over an existing file") + } catch let error as ContainerizationError where error.code == .invalidArgument { + guard error.description.contains("cannot copy directory over existing file") else { + throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)") + } + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + func testCopyOut() async throws { let id = "test-copy-out" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9eec1248..a69ce81e 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -326,6 +326,9 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container capabilities file ownership", testCapabilitiesFileOwnership), Test("container stat", testStat), Test("container copy in", testCopyIn), + Test("container copy in file to existing directory", testCopyInFileToExistingDirectory), + Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails), + Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails), Test("container copy out", testCopyOut), Test("container copy large file", testCopyLargeFile), Test("container copy in directory", testCopyInDirectory),