Skip to content
Merged
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
241 changes: 241 additions & 0 deletions Sources/Containerization/FileMount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

#if os(macOS)

import ContainerizationError
import ContainerizationOCI
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.
struct FileMountContext: Sendable {
/// Metadata for a single prepared file mount.
struct PreparedMount: Sendable {
/// Original file path on host
let hostFilePath: String
/// Where the user wants the file in the container
let containerDestination: String
/// Just the filename
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
let tag: String
/// Mount options from the original mount
let options: [String]
/// Where we mounted the share in the guest (set after mountHoldingDirectories)
var guestHoldingPath: String?
}

/// Prepared file mounts for this context
var preparedMounts: [PreparedMount]

/// The transformed mounts to pass to the VM (files replaced with directory shares)
private(set) var transformedMounts: [Mount]

private init() {
self.preparedMounts = []
self.transformedMounts = []
}

/// Returns true if there are any file mounts that need handling.
var hasFileMounts: Bool {
!preparedMounts.isEmpty
}

/// Returns the set of virtiofs tags for file mount holding directories.
/// These should be filtered out from OCI spec mounts since we mount them
/// separately under /run.
var holdingDirectoryTags: Set<String> {
Set(preparedMounts.map { $0.tag })
}
}

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.
///
/// - 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] = []

for mount in mounts {
// Only virtiofs mounts can be files
guard case .virtiofs(let runtimeOpts) = mount.runtimeOptions else {
transformed.append(mount)
continue
}

// Stat the source to see if it's a file
let fm = FileManager.default
var isDirectory: ObjCBool = false
guard fm.fileExists(atPath: mount.source, isDirectory: &isDirectory) else {
// Doesn't exist. Let the normal flow handle the error
transformed.append(mount)
continue
}

if isDirectory.boolValue {
// It's a directory, pass through unchanged
transformed.append(mount)
continue
}

// 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)
}

context.transformedMounts = transformed
return context
}

private mutating func prepareFileMount(
mount: Mount,
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want our documentation on Mount to state that the caller is responsible for determining whether cross-filesystem mounts are present.

For "caller == container" we'd want to warn the user in the case that their single file mount won't actually propagate guest writes through to the host file.

}

let tag = try hashMountSource(source: tempDir.path)

let prepared = PreparedMount(
hostFilePath: mount.source,
containerDestination: mount.destination,
filename: filename,
tempDirectory: tempDir,
tag: tag,
options: mount.options,
guestHoldingPath: nil
)

preparedMounts.append(prepared)
return prepared
}
}

extension FileMountContext {
/// Mount the holding directories in the guest for all file mounts.
/// - Parameters:
/// - vmMounts: The AttachedFilesystem array from the VM for this container
/// - agent: The VM agent for RPCs
mutating func mountHoldingDirectories(
vmMounts: [AttachedFilesystem],
agent: any VirtualMachineAgent
) async throws {
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: []
))

preparedMounts[i].guestHoldingPath = guestPath
}
}
}

extension FileMountContext {
/// Get the bind mounts to append to the OCI spec.
func ociBindMounts() -> [ContainerizationOCI.Mount] {
preparedMounts.compactMap { prepared in
guard let guestPath = prepared.guestHoldingPath else {
return nil
}

return ContainerizationOCI.Mount(
type: "none",
source: "\(guestPath)/\(prepared.filename)",
destination: prepared.containerDestination,
options: ["bind"] + prepared.options
)
}
}
}

extension FileMountContext {
/// Clean up temp directories.
func cleanup() {
let fm = FileManager.default
for prepared in preparedMounts {
try? fm.removeItem(at: prepared.tempDirectory)
}
}
}

#endif
37 changes: 34 additions & 3 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,26 +126,30 @@ public final class LinuxContainer: Container, Sendable {
struct CreatedState: Sendable {
let vm: any VirtualMachineInstance
let relayManager: UnixSocketRelayManager
var fileMountContext: FileMountContext
}

struct StartedState: Sendable {
let vm: any VirtualMachineInstance
let process: LinuxProcess
let relayManager: UnixSocketRelayManager
var vendedProcesses: [String: LinuxProcess]
let fileMountContext: FileMountContext

init(_ state: CreatedState, process: LinuxProcess) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = process
self.vendedProcesses = [:]
self.fileMountContext = state.fileMountContext
}

init(_ state: PausedState) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.fileMountContext = state.fileMountContext
}
}

Expand All @@ -154,12 +158,14 @@ public final class LinuxContainer: Container, Sendable {
let relayManager: UnixSocketRelayManager
let process: LinuxProcess
var vendedProcesses: [String: LinuxProcess]
let fileMountContext: FileMountContext

init(_ state: StartedState) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.fileMountContext = state.fileMountContext
}
}

Expand Down Expand Up @@ -417,11 +423,16 @@ extension LinuxContainer {
ProcessInfo.processInfo.physicalMemory
)

// Prepare file mounts. This transforms single-file mounts into directory shares.
let fileMountContext = try FileMountContext.prepare(mounts: self.config.mounts)
// This is dumb, but alas.
let fileMountContextHolder = Mutex<FileMountContext>(fileMountContext)

let vmConfig = VMConfiguration(
cpus: self.cpus,
memoryInBytes: vmMemory,
interfaces: self.interfaces,
mountsByID: [self.id: [modifiedRootfs] + self.config.mounts],
mountsByID: [self.id: [modifiedRootfs] + fileMountContext.transformedMounts],
bootLog: self.config.bootLog,
nestedVirtualization: self.config.virtualization
)
Expand All @@ -442,6 +453,17 @@ extension LinuxContainer {
rootfs.destination = Self.guestRootfsPath(self.id)
try await agent.mount(rootfs)

// Mount file mount holding directories under /run.
if fileMountContext.hasFileMounts {
let containerMounts = vm.mounts[self.id] ?? []
var ctx = fileMountContextHolder.withLock { $0 }
try await ctx.mountHoldingDirectories(
vmMounts: containerMounts,
agent: agent
)
fileMountContextHolder.withLock { $0 = ctx }
}

// Start up our friendly unix socket relays.
for socket in self.config.sockets {
try await self.relayUnixSocket(
Expand Down Expand Up @@ -473,7 +495,7 @@ extension LinuxContainer {
}

}
state = .created(.init(vm: vm, relayManager: relayManager))
state = .created(.init(vm: vm, relayManager: relayManager, fileMountContext: fileMountContextHolder.withLock { $0 }))
} catch {
try? await relayManager.stopAll()
try? await vm.stop()
Expand All @@ -492,8 +514,14 @@ extension LinuxContainer {
do {
var spec = self.generateRuntimeSpec()
// We don't need the rootfs, nor do OCI runtimes want it included.
// Also filter out file mount holding directories. We'll mount those separately under /run.
let containerMounts = createdState.vm.mounts[self.id] ?? []
spec.mounts = containerMounts.dropFirst().map { $0.to }
let holdingTags = createdState.fileMountContext.holdingDirectoryTags
spec.mounts =
containerMounts.dropFirst()
.filter { !holdingTags.contains($0.source) }
.map { $0.to }
+ createdState.fileMountContext.ociBindMounts()

let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
Expand Down Expand Up @@ -641,6 +669,9 @@ extension LinuxContainer {
firstError = firstError ?? error
}

// Clean up file mount temporary directories.
startedState.fileMountContext.cleanup()

do {
try await vm.stop()
state = .stopped
Expand Down
Loading