Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import Synchronization
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion Sources/Containerization/Vminitd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
150 changes: 150 additions & 0 deletions Sources/Integration/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions vminitd/Sources/VminitdCore/Server+GRPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down