diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index b8eda640..7730f647 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 @@ -1064,7 +1065,19 @@ extension LinuxContainer { } let isArchive = isDirectory.boolValue - let guestPath = URL(filePath: self.root).appending(path: destination.path) + 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") + } + + return try await self.resolveCopyInGuestPath( + from: source, + to: destination, + sourceIsDirectory: isArchive, + using: vminitd + ) + } + let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue let listener = try state.vm.listen(port) @@ -1149,6 +1162,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/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index eaf6e50b..2cd588ec 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -1785,6 +1785,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), 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 {