From 75fa631b8d183eddc1834a5e153e3b002e206da6 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Tue, 7 Apr 2026 18:28:48 -0700 Subject: [PATCH] Mounts: Change single file mounts to a different approach Change to sharing in the parent directory, and then bind mounting in the file into the container. This has the unfortunate reality of being less secure, but the current approach is burdended by a couple things, namely: 1. You can't share in files that are on a different volume 2. There is a Virtualization bug that causes spurious errors when trying to open the file. I've added a doc to go over the approach we've taken, and some workarounds if the approach is not satisfactory. --- Sources/Containerization/FileMount.swift | 132 ++++++++---------- Sources/Containerization/LinuxContainer.swift | 6 - Sources/Containerization/LinuxPod.swift | 3 - Sources/Integration/Suite.swift | 11 +- docs/single-file-mounts.md | 55 ++++++++ 5 files changed, 115 insertions(+), 92 deletions(-) create mode 100644 docs/single-file-mounts.md diff --git a/Sources/Containerization/FileMount.swift b/Sources/Containerization/FileMount.swift index d0d6cea4..68112f76 100644 --- a/Sources/Containerization/FileMount.swift +++ b/Sources/Containerization/FileMount.swift @@ -21,15 +21,9 @@ import Foundation /// Manages single-file mounts by transforming them into virtiofs directory shares /// plus bind mounts. /// -/// Since virtiofs only supports sharing directories, mounting a single file without -/// exposing the other potential files in that directory needs a little bit of a "hack". -/// The one we've landed on is: -/// -/// 1. Creating a temporary directory containing a hardlink to the file -/// 2. Sharing that directory via virtiofs to a holding location in the guest -/// 3. Bind mounting the specific file from the holding location to the final destination -/// -/// This type handles all three steps transparently. +/// Since virtiofs only supports sharing directories, mounting a single file requires +/// sharing the file's parent directory via virtiofs and then bind mounting the specific +/// file from that share to the final destination in the container. struct FileMountContext: Sendable { /// Metadata for a single prepared file mount. struct PreparedMount: Sendable { @@ -37,11 +31,11 @@ struct FileMountContext: Sendable { let hostFilePath: String /// Where the user wants the file in the container let containerDestination: String - /// Just the filename + /// Just the filename (after resolving symlinks) let filename: String - /// Temp directory containing the hardlinked file - let tempDirectory: URL - /// The virtiofs tag (hash of temp dir path). Used to find the AttachedFilesystem + /// The parent directory containing the file (after resolving symlinks) + let parentDirectory: URL + /// The virtiofs tag (hash of parent dir path). Used to find the AttachedFilesystem let tag: String /// Mount options from the original mount let options: [String] @@ -77,14 +71,16 @@ extension FileMountContext { /// Prepare mounts for a container, detecting file mounts and transforming them. /// /// This method stats each virtiofs mount source. If it's a regular file rather than - /// a directory, it creates a temporary directory with a hardlink to the file and - /// substitutes a directory share for the original mount. + /// a directory, it shares the file's parent directory via virtiofs and records the + /// metadata needed to bind mount the specific file later. /// /// - Parameter mounts: The original mounts from the container config /// - Returns: A FileMountContext containing transformed mounts and tracking info static func prepare(mounts: [Mount]) throws -> FileMountContext { var context = FileMountContext() var transformed: [Mount] = [] + // Track parent directories we've already added a share for to avoid duplicates. + var sharedParentTags: Set = [] for mount in mounts { // Only virtiofs mounts can be files @@ -111,15 +107,17 @@ extension FileMountContext { // It's a file, so prepare it. let prepared = try context.prepareFileMount(mount: mount, runtimeOptions: runtimeOpts) - // Create a regular directory share for the temp directory. - // The destination here is unused. We'll mount it ourselves to a location under /run. - let directoryShare = Mount.share( - source: prepared.tempDirectory.path, - destination: "/.file-mount-holding", - options: mount.options.filter { $0 != "bind" }, - runtimeOptions: runtimeOpts - ) - transformed.append(directoryShare) + // Only add the directory share once per unique parent directory. + if !sharedParentTags.contains(prepared.tag) { + sharedParentTags.insert(prepared.tag) + let directoryShare = Mount.share( + source: prepared.parentDirectory.path, + destination: "/.file-mount-holding", + options: mount.options.filter { $0 != "bind" }, + runtimeOptions: runtimeOpts + ) + transformed.append(directoryShare) + } } context.transformedMounts = transformed @@ -131,34 +129,15 @@ extension FileMountContext { runtimeOptions: [String] ) throws -> PreparedMount { let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath() - let sourceURL = URL(fileURLWithPath: mount.source) - let filename = sourceURL.lastPathComponent - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("containerization-file-mounts") - .appendingPathComponent(UUID().uuidString) - - try FileManager.default.createDirectory( - at: tempDir, - withIntermediateDirectories: true - ) - - // Hardlink the file (falls back to copy if cross-filesystem) - let destURL = tempDir.appendingPathComponent(filename) - do { - try FileManager.default.linkItem(at: resolvedSource, to: destURL) - } catch { - // Hardlink failed. Fall back to copy - try FileManager.default.copyItem(at: resolvedSource, to: destURL) - } - - let tag = try hashMountSource(source: tempDir.path) + let filename = resolvedSource.lastPathComponent + let parentDirectory = resolvedSource.deletingLastPathComponent() + let tag = try hashMountSource(source: parentDirectory.path) let prepared = PreparedMount( hostFilePath: mount.source, containerDestination: mount.destination, filename: filename, - tempDirectory: tempDir, + parentDirectory: parentDirectory, tag: tag, options: mount.options, guestHoldingPath: nil @@ -178,30 +157,39 @@ extension FileMountContext { vmMounts: [AttachedFilesystem], agent: any VirtualMachineAgent ) async throws { + // Track which tags we've already mounted to avoid duplicate mounts + // when multiple files share the same parent directory. + var mountedTags: Set = [] + for i in preparedMounts.indices { let prepared = preparedMounts[i] - // Find the attached filesystem by matching the virtiofs tag - guard - let attached = vmMounts.first(where: { - $0.type == "virtiofs" && $0.source == prepared.tag - }) - else { - throw ContainerizationError( - .notFound, - message: "could not find attached filesystem for file mount \(prepared.hostFilePath)" - ) - } - let guestPath = "/run/file-mounts/\(prepared.tag)" - try await agent.mkdir(path: guestPath, all: true, perms: 0o755) - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: attached.source, - destination: guestPath, - options: [] - )) + + if !mountedTags.contains(prepared.tag) { + // Find the attached filesystem by matching the virtiofs tag + guard + let attached = vmMounts.first(where: { + $0.type == "virtiofs" && $0.source == prepared.tag + }) + else { + throw ContainerizationError( + .notFound, + message: "could not find attached filesystem for file mount \(prepared.hostFilePath)" + ) + } + + try await agent.mkdir(path: guestPath, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: attached.source, + destination: guestPath, + options: [] + )) + + mountedTags.insert(prepared.tag) + } preparedMounts[i].guestHoldingPath = guestPath } @@ -225,13 +213,3 @@ extension FileMountContext { } } } - -extension FileMountContext { - /// Clean up temp directories. - func cleanUp() { - let fm = FileManager.default - for prepared in preparedMounts { - try? fm.removeItem(at: prepared.tempDirectory) - } - } -} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index e4596667..8fa95a45 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -711,18 +711,15 @@ extension LinuxContainer { let vm: any VirtualMachineInstance let relayManager: UnixSocketRelayManager - let fileMountContext: FileMountContext let startedState = try? state.startedState("stop") if let startedState { vm = startedState.vm relayManager = startedState.relayManager - fileMountContext = startedState.fileMountContext } else { let createdState = try state.createdState("stop") vm = createdState.vm relayManager = createdState.relayManager - fileMountContext = createdState.fileMountContext } var firstError: Error? @@ -802,9 +799,6 @@ extension LinuxContainer { } } - // Clean up file mount temporary directories. - fileMountContext.cleanUp() - do { try await vm.stop() state = .stopped diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 563c07ef..a37ce062 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -715,9 +715,6 @@ extension LinuxPod { container.process = nil container.state = .stopped - // Clean up file mount temporary directories. - container.fileMountContext.cleanUp() - state.containers[containerID] = container } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 0ff387c0..85341144 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -353,11 +353,10 @@ struct IntegrationSuite: AsyncParsableCommand { Test("stdin binary data", testStdinBinaryData), Test("stdin multiple chunks", testStdinMultipleChunks), Test("stdin very large", testStdinVeryLarge), - // FIXME: reenable when single file mount issues resolved - //Test("container single file mount", testSingleFileMount), - //Test("container single file mount read-only", testSingleFileMountReadOnly), - //Test("container single file mount write-back", testSingleFileMountWriteBack), - //Test("container single file mount symlink", testSingleFileMountSymlink), + Test("container single file mount", testSingleFileMount), + Test("container single file mount read-only", testSingleFileMountReadOnly), + Test("container single file mount write-back", testSingleFileMountWriteBack), + Test("container single file mount symlink", testSingleFileMountSymlink), Test("container rlimit open files", testRLimitOpenFiles), Test("container rlimit multiple", testRLimitMultiple), Test("container rlimit exec", testRLimitExec), @@ -398,7 +397,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod shared PID namespace", testPodSharedPIDNamespace), Test("pod read-only rootfs", testPodReadOnlyRootfs), Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured), - //Test("pod single file mount", testPodSingleFileMount), + Test("pod single file mount", testPodSingleFileMount), Test("pod container hosts config", testPodContainerHostsConfig), Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS), Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts), diff --git a/docs/single-file-mounts.md b/docs/single-file-mounts.md new file mode 100644 index 00000000..34544f71 --- /dev/null +++ b/docs/single-file-mounts.md @@ -0,0 +1,55 @@ +# Single File Mounts + +In Containerization, what is analogous to bind mounts goes over virtiofs. virtiofs can only +share directories, not individual files. To support mounting a single file from the host into +a container, Containerization shares the file's parent directory via virtiofs and then bind +mounts the specific file to its final destination inside the container. + +## How it works + +1. **Detection**: During mount preparation, each virtiofs mount source is stat'd. If it's a + regular file (not a directory), it enters the single-file mount path. Symlinks are + resolved to the real file first. + +2. **Parent directory share**: The file's parent directory is shared via virtiofs into the + guest VM. If multiple single-file mounts reference files in the same parent directory, + only one virtiofs share is created. + +3. **Guest holding mount**: After the VM starts, the parent directory share is mounted to a + holding location in the guest. + +4. **Bind mount**: When the container starts, a bind mount is created from + the holding location to the requested destination path inside the container. + +### Example + +Mounting `/Users/dev/config/app.toml` to `/etc/app.toml` in the container: + +``` +Host: /Users/dev/config/ (shared via virtiofs) +Guest VM: /temporary/holding/spot/ (virtiofs mount of parent dir) +Container: /etc/app.toml (bind mount of /temporary/holding/spot/app.toml) +``` + +## Trade-offs + +Sharing the parent directory means that sibling files in that directory are visible to the +guest VM at the holding mount point under `/run`. The bind mount into the container only +exposes the specific file requested, but the full parent directory contents are accessible +from inside the VM itself. This is a deliberate trade-off for reliability. Prior attempts +at supporting single file mounts using temporary directories with hardlinks were fragile +across filesystem boundaries and with certain host filesystem configurations. + +## Alternatives to single file mounts + +If exposing the parent directory to the guest VM is not acceptable for your use case, you +can avoid single-file mounts entirely: + +- **Mount the whole directory**: Instead of mounting a single file, mount the directory that + contains it. This is functionally equivalent (the directory is shared either way) but makes + the behavior explicit and gives the container access to the full directory at the + destination path. + +- **Stage files into a dedicated directory**: Copy or link the files you need into a + dedicated directory on the host and mount that directory instead. This gives you full + control over what is visible to the guest.