Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a20c3f9
Add file mount detection and parent directory sharing
Sunsvea Aug 6, 2025
429a379
Implement bind mount functionality for single files
Sunsvea Aug 6, 2025
eb4a1ee
Add tests for single file mount detection
Sunsvea Aug 6, 2025
6a32cdf
Fix mounts property reference in bind mount logic
Sunsvea Aug 6, 2025
dedb89f
Fix AttachedFilesystem property access in mount logic
Sunsvea Aug 6, 2025
d9f7afe
Fix allocator usage in mount tests
Sunsvea Aug 6, 2025
3f81e5f
Run formatter
Sunsvea Aug 6, 2025
dd0b78a
Merge branch 'main' into feature/single-file-mount-support
Sunsvea Aug 6, 2025
2f5c7b2
Address reviewer feedback: rename isFileBind to isFile and revert mou…
Sunsvea Aug 6, 2025
b6730f6
Revert rootfs mount to use original .to pattern for consistency
Sunsvea Aug 6, 2025
9252ef5
Revert spec.mounts to use clean .to pattern instead of verbose .init
Sunsvea Aug 6, 2025
0e23a8c
Add integration test coverage for single file mount support and impro…
Sunsvea Aug 6, 2025
75fd986
Implement hardlink-based file isolation for single file mounts
Sunsvea Aug 7, 2025
0b2e6d3
Remove bind mount logic as hardlinked files are directly accessible
Sunsvea Aug 7, 2025
ca23072
Update AttachedFilesystem to use deterministic hardlink isolation and…
Sunsvea Aug 7, 2025
d117e5a
Add comprehensive unit tests for hardlink isolation and fix Foundatio…
Sunsvea Aug 7, 2025
22aa0d9
Apply code formatting to hardlink isolation implementation
Sunsvea Aug 7, 2025
29f5518
Merge branch 'main' into feature/single-file-mount-support
Sunsvea Aug 7, 2025
d6ad5e7
feat: add mount consolidation for multiple single file mounts
Sunsvea Aug 11, 2025
eb9decd
Merge branch 'main' into feature/single-file-mount-support
Sunsvea Aug 11, 2025
7d2aba9
feat: add security hardening for file mount isolation with atomic ope…
Sunsvea Aug 11, 2025
9104a3d
fix: add race condition protection to createIsolatedFileShare()
Sunsvea Aug 15, 2025
ce04bc1
fix: prevent temp directory collisions in createIsolatedFileShare()
Sunsvea Aug 15, 2025
2e8a5fe
Merge branch 'origin/main' into feature/single-file-mount-support
Sunsvea Aug 15, 2025
0107d82
fix: resolve race condition in Mount tests with UUID-based temp direc…
Sunsvea Aug 15, 2025
648a7ba
chore: run code formatter
Sunsvea Aug 15, 2025
b09860c
fix: resolve race conditions in tests when executed in parallel
Sunsvea Aug 15, 2025
55cbcac
chore: run code formatter
Sunsvea Aug 15, 2025
07ee4ff
refactor: remove redundant caching mechanism
Sunsvea Aug 15, 2025
0636693
fix: filename conflicts in tests when run in parallel
Sunsvea Aug 18, 2025
442718a
Merge branch 'main' into feature/single-file-mount-support
Sunsvea Aug 18, 2025
c8b5a9d
Merge branch 'main' into feature/single-file-mount-support
Sunsvea Sep 11, 2025
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
21 changes: 19 additions & 2 deletions Sources/Containerization/AttachedFilesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ContainerizationExtras
import ContainerizationOCI
import Foundation

/// A filesystem that was attached and able to be mounted inside the runtime environment.
public struct AttachedFilesystem: Sendable {
Expand All @@ -27,12 +28,22 @@ public struct AttachedFilesystem: Sendable {
public var destination: String
/// The options to use when mounting the filesystem.
public var options: [String]
/// True if this is a single file mount using hardlink isolation
var isFile: Bool

#if os(macOS)
public init(mount: Mount, allocator: any AddressAllocator<Character>) throws {
self.isFile = mount.isFile

switch mount.type {
case "virtiofs":
let name = try hashMountSource(source: mount.source)
let shareSource: String
if mount.isFile {
shareSource = try mount.createIsolatedFileShare()
} else {
shareSource = mount.source
}
let name = try hashMountSource(source: shareSource)
self.source = name
case "ext4":
let char = try allocator.allocate()
Expand All @@ -42,7 +53,13 @@ public struct AttachedFilesystem: Sendable {
}
self.type = mount.type
self.options = mount.options
self.destination = mount.destination

// For file mounts with hardlink isolation, mount at parent directory
if mount.isFile && mount.type == "virtiofs" {
self.destination = URL(fileURLWithPath: mount.destination).deletingLastPathComponent().path
} else {
self.destination = mount.destination
}
}
#endif
}
48 changes: 45 additions & 3 deletions Sources/Containerization/Mount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,41 @@ public struct Mount: Sendable {
#if os(macOS)

extension Mount {
var isFile: Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: self.source, isDirectory: &isDirectory)
return exists && !isDirectory.boolValue
}

var parentDirectory: String {
URL(fileURLWithPath: self.source).deletingLastPathComponent().path
}

var filename: String {
URL(fileURLWithPath: self.source).lastPathComponent
}

/// Create an isolated temporary directory containing only the target file via hardlink
func createIsolatedFileShare() throws -> String {
// Create deterministic temp directory based on source file path
let sourceHash = try hashMountSource(source: self.source)
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("containerization-file-mount-\(sourceHash)")

// Create directory if it doesn't exist
if !FileManager.default.fileExists(atPath: tempDir.path) {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)

let isolatedFile = tempDir.appendingPathComponent(filename)
let sourceFile = URL(fileURLWithPath: self.source)

// Create hardlink to isolate the single file
try FileManager.default.linkItem(at: sourceFile, to: isolatedFile)
}

return tempDir.path
}

func configure(config: inout VZVirtualMachineConfiguration) throws {
switch self.runtimeOptions {
case .virtioblk(let options):
Expand All @@ -140,11 +175,18 @@ extension Mount {
config.storageDevices.append(attachment)
case .virtiofs(_):
guard FileManager.default.fileExists(atPath: self.source) else {
throw ContainerizationError(.notFound, message: "directory \(source) does not exist")
throw ContainerizationError(.notFound, message: "path \(source) does not exist")
}

let shareSource: String
if isFile {
shareSource = try createIsolatedFileShare()
} else {
shareSource = self.source
}

let name = try hashMountSource(source: self.source)
let urlSource = URL(fileURLWithPath: source)
let name = try hashMountSource(source: shareSource)
let urlSource = URL(fileURLWithPath: shareSource)

let device = VZVirtioFileSystemDeviceConfiguration(tag: name)
device.share = VZSingleDirectoryShare(
Expand Down
2 changes: 2 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ struct IntegrationSuite: AsyncParsableCommand {
"container hostname": testHostname,
"container hosts": testHostsFile,
"container mount": testMounts,
"container single file mount": testSingleFileMount,
"container multiple single file mounts": testMultipleSingleFileMounts,
"nested virt": testNestedVirtualizationEnabled,
"container manager": testContainerManagerCreate,
"container reuse": testContainerReuse,
Expand Down
69 changes: 69 additions & 0 deletions Sources/Integration/VMTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,78 @@ extension IntegrationSuite {
}
}

func testSingleFileMount() async throws {
let id = "test-single-file-mount"

let bs = try await bootstrap()
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
let tempFile = try createSingleMountFile()
config.process.arguments = ["/bin/cat", "/app/config.txt"]
config.mounts.append(.share(source: tempFile.path, destination: "/app/config.txt"))
config.process.stdout = buffer
}

try await container.create()
try await container.start()

let status = try await container.wait()
try await container.stop()

guard status == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}

let value = String(data: buffer.data, encoding: .utf8)
guard value == "single file content" else {
throw IntegrationError.assert(
msg: "process should have returned 'single file content' != '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'")
}
}

func testMultipleSingleFileMounts() async throws {
let id = "test-multiple-single-file-mounts"

let bs = try await bootstrap()
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
let configFile = try createSingleMountFile(content: "config data")
let secretFile = try createSingleMountFile(content: "secret data")

config.process.arguments = ["/bin/sh", "-c", "cat /app/config.txt && echo '---' && cat /app/secret.txt"]
config.mounts.append(.share(source: configFile.path, destination: "/app/config.txt"))
config.mounts.append(.share(source: secretFile.path, destination: "/app/secret.txt"))
config.process.stdout = buffer
}

try await container.create()
try await container.start()

let status = try await container.wait()
try await container.stop()

guard status == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}

let value = String(data: buffer.data, encoding: .utf8)
let expected = "config data---\nsecret data"
guard value == expected else {
throw IntegrationError.assert(
msg: "process should have returned '\(expected)' != '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'")
}
}

private func createMountDirectory() throws -> URL {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8)
return dir
}

private func createSingleMountFile(content: String = "single file content") throws -> URL {
let tempFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-single-file-\(UUID().uuidString).txt")
try content.write(to: tempFile, atomically: true, encoding: .utf8)
return tempFile
}
}
Loading