Skip to content

Commit db5b5b9

Browse files
Add path resolution for CopyIn using Stat (#727)
This pull request enhances the handling of file and directory copy operations in Linux containers, particularly by improving destination path resolution and error handling for the `copyIn` operation with the `Stat RPC` This PR is needed for [container#1190](apple/container#1190)
1 parent 872f601 commit db5b5b9

5 files changed

Lines changed: 220 additions & 2 deletions

File tree

Sources/Containerization/LinuxContainer.swift

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import ContainerizationArchive
1818
import ContainerizationError
1919
import ContainerizationExtras
2020
import ContainerizationOCI
21+
import ContainerizationOS
2122
import Foundation
2223
import Logging
2324
import Synchronization
@@ -1094,7 +1095,19 @@ extension LinuxContainer {
10941095
}
10951096
let isArchive = isDirectory.boolValue
10961097

1097-
let guestPath = URL(filePath: self.root).appending(path: destination.path)
1098+
let guestPath: URL = try await state.vm.withAgent { agent in
1099+
guard let vminitd = agent as? Vminitd else {
1100+
throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent")
1101+
}
1102+
1103+
return try await self.resolveCopyInGuestPath(
1104+
from: source,
1105+
to: destination,
1106+
sourceIsDirectory: isArchive,
1107+
using: vminitd
1108+
)
1109+
}
1110+
10981111
let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
10991112
let listener = try state.vm.listen(port)
11001113

@@ -1179,6 +1192,46 @@ extension LinuxContainer {
11791192
}
11801193
}
11811194

1195+
private func resolveCopyInGuestPath(
1196+
from source: URL,
1197+
to destination: URL,
1198+
sourceIsDirectory: Bool,
1199+
using vminitd: Vminitd
1200+
) async throws -> URL {
1201+
let guestDestination = URL(filePath: self.root).appending(path: destination.path)
1202+
1203+
let stat: ContainerizationOS.Stat?
1204+
do {
1205+
stat = try await vminitd.stat(path: guestDestination)
1206+
} catch let error as ContainerizationError where error.code == .notFound {
1207+
stat = nil
1208+
}
1209+
// Any other error propagates so transport and permission failures are visible.
1210+
1211+
guard let stat else {
1212+
if destination.hasDirectoryPath && !sourceIsDirectory {
1213+
throw ContainerizationError(
1214+
.invalidArgument,
1215+
message: "destination directory does not exist: \(destination.path)"
1216+
)
1217+
}
1218+
return guestDestination
1219+
}
1220+
1221+
let destinationIsDirectory = (stat.mode & UInt32(S_IFMT)) == UInt32(S_IFDIR)
1222+
guard destinationIsDirectory else {
1223+
if sourceIsDirectory {
1224+
throw ContainerizationError(
1225+
.invalidArgument,
1226+
message: "cannot copy directory over existing file: \(destination.path)"
1227+
)
1228+
}
1229+
return guestDestination
1230+
}
1231+
1232+
return guestDestination.appendingPathComponent(source.lastPathComponent)
1233+
}
1234+
11821235
/// Copy a file or directory from the container to the host.
11831236
///
11841237
/// Data transfer happens over a dedicated vsock connection. For directories,

Sources/Containerization/Vminitd.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,12 @@ extension Vminitd {
472472
$0.path = path.path
473473
}
474474

475-
let response = try await client.stat(request)
475+
let response: Com_Apple_Containerization_Sandbox_V3_StatResponse
476+
do {
477+
response = try await client.stat(request)
478+
} catch let error as RPCError where error.code == .notFound {
479+
throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error)
480+
}
476481
guard response.error.isEmpty else {
477482
throw ContainerizationError(.internalError, message: "stat: \(response.error)")
478483
}

Sources/Integration/ContainerTests.swift

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,6 +1843,156 @@ extension IntegrationSuite {
18431843
}
18441844
}
18451845

1846+
func testCopyInFileToExistingDirectory() async throws {
1847+
let id = "test-copy-in-file-to-dir"
1848+
1849+
let bs = try await bootstrap(id)
1850+
1851+
let testContent = "copy into an existing guest directory"
1852+
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
1853+
.appendingPathComponent("host-file.txt")
1854+
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
1855+
1856+
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
1857+
config.process.arguments = ["sleep", "100"]
1858+
config.bootLog = bs.bootLog
1859+
}
1860+
1861+
do {
1862+
try await container.create()
1863+
try await container.start()
1864+
1865+
let mkdir = try await container.exec("create-copy-target") { config in
1866+
config.arguments = ["mkdir", "-p", "/tmp/copy-target"]
1867+
}
1868+
try await mkdir.start()
1869+
let mkdirStatus = try await mkdir.wait()
1870+
try await mkdir.delete()
1871+
1872+
guard mkdirStatus.exitCode == 0 else {
1873+
throw IntegrationError.assert(msg: "mkdir failed with status \(mkdirStatus)")
1874+
}
1875+
1876+
try await container.copyIn(
1877+
from: hostFile,
1878+
to: URL(filePath: "/tmp/copy-target")
1879+
)
1880+
1881+
let buffer = BufferWriter()
1882+
let verify = try await container.exec("verify-copy-target") { config in
1883+
config.arguments = ["cat", "/tmp/copy-target/host-file.txt"]
1884+
config.stdout = buffer
1885+
}
1886+
try await verify.start()
1887+
let verifyStatus = try await verify.wait()
1888+
try await verify.delete()
1889+
1890+
guard verifyStatus.exitCode == 0 else {
1891+
throw IntegrationError.assert(msg: "cat copied file failed with status \(verifyStatus)")
1892+
}
1893+
guard String(data: buffer.data, encoding: .utf8) == testContent else {
1894+
throw IntegrationError.assert(msg: "copied file should land under the existing destination directory")
1895+
}
1896+
1897+
try await container.kill(.kill)
1898+
try await container.wait()
1899+
try await container.stop()
1900+
} catch {
1901+
try? await container.stop()
1902+
throw error
1903+
}
1904+
}
1905+
1906+
func testCopyInFileToMissingDirectoryFails() async throws {
1907+
let id = "test-copy-in-file-missing-dir"
1908+
1909+
let bs = try await bootstrap(id)
1910+
1911+
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
1912+
.appendingPathComponent("host-file.txt")
1913+
try "missing destination directory".write(to: hostFile, atomically: true, encoding: .utf8)
1914+
1915+
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
1916+
config.process.arguments = ["sleep", "100"]
1917+
config.bootLog = bs.bootLog
1918+
}
1919+
1920+
do {
1921+
try await container.create()
1922+
try await container.start()
1923+
1924+
do {
1925+
try await container.copyIn(
1926+
from: hostFile,
1927+
to: URL(filePath: "/tmp/missing-copy-target/")
1928+
)
1929+
throw IntegrationError.assert(msg: "copyIn should fail when copying a file to a missing destination directory")
1930+
} catch let error as ContainerizationError where error.code == .invalidArgument {
1931+
guard error.description.contains("destination directory does not exist") else {
1932+
throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)")
1933+
}
1934+
}
1935+
1936+
try await container.kill(.kill)
1937+
try await container.wait()
1938+
try await container.stop()
1939+
} catch {
1940+
try? await container.stop()
1941+
throw error
1942+
}
1943+
}
1944+
1945+
func testCopyInDirectoryOverExistingFileFails() async throws {
1946+
let id = "test-copy-in-dir-over-file"
1947+
1948+
let bs = try await bootstrap(id)
1949+
1950+
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
1951+
.appendingPathComponent("host-dir")
1952+
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
1953+
try "directory content".write(to: hostDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
1954+
1955+
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
1956+
config.process.arguments = ["sleep", "100"]
1957+
config.bootLog = bs.bootLog
1958+
}
1959+
1960+
do {
1961+
try await container.create()
1962+
try await container.start()
1963+
1964+
let createFile = try await container.exec("create-existing-file") { config in
1965+
config.arguments = ["sh", "-c", "echo -n existing > /tmp/existing-file"]
1966+
}
1967+
try await createFile.start()
1968+
let createStatus = try await createFile.wait()
1969+
try await createFile.delete()
1970+
1971+
guard createStatus.exitCode == 0 else {
1972+
throw IntegrationError.assert(msg: "failed to create existing file, status \(createStatus)")
1973+
}
1974+
1975+
do {
1976+
try await container.copyIn(
1977+
from: hostDir,
1978+
to: URL(filePath: "/tmp/existing-file")
1979+
)
1980+
throw IntegrationError.assert(msg: "copyIn should fail when copying a directory over an existing file")
1981+
} catch let error as ContainerizationError where error.code == .invalidArgument {
1982+
guard error.description.contains("cannot copy directory over existing file") else {
1983+
throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)")
1984+
}
1985+
}
1986+
1987+
try await container.kill(.kill)
1988+
try await container.wait()
1989+
try await container.stop()
1990+
} catch {
1991+
try? await container.stop()
1992+
throw error
1993+
}
1994+
}
1995+
18461996
func testCopyOut() async throws {
18471997
let id = "test-copy-out"
18481998

Sources/Integration/Suite.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ struct IntegrationSuite: AsyncParsableCommand {
327327
Test("container capabilities file ownership", testCapabilitiesFileOwnership),
328328
Test("container stat", testStat),
329329
Test("container copy in", testCopyIn),
330+
Test("container copy in file to existing directory", testCopyInFileToExistingDirectory),
331+
Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails),
332+
Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails),
330333
Test("container copy out", testCopyOut),
331334
Test("container copy large file", testCopyLargeFile),
332335
Test("container copy in directory", testCopyInDirectory),

vminitd/Sources/VminitdCore/Server+GRPC.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,13 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ
384384
let result = _stat(request.path, &s)
385385
if result == -1 {
386386
let error = swiftErrno("stat")
387+
if error.code == .ENOENT {
388+
throw RPCError(
389+
code: .notFound,
390+
message: "stat: path not found '\(request.path)'",
391+
cause: error
392+
)
393+
}
387394
return .with { $0.error = "\(error)" }
388395
}
389396
return .with {

0 commit comments

Comments
 (0)