Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
7 changes: 6 additions & 1 deletion Sources/Containerization/AttachedFilesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ 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 requiring bind mounting
public var isFileBind: Bool

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

switch mount.type {
case "virtiofs":
let name = try hashMountSource(source: mount.source)
let shareSource = mount.isFile ? mount.parentDirectory : mount.source
let name = try hashMountSource(source: shareSource)
self.source = name
case "ext4":
let char = try allocator.allocate()
Expand Down
28 changes: 25 additions & 3 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,29 @@ extension LinuxContainer {
try await agent.standardSetup()

// Mount the rootfs.
var rootfs = vm.mounts[0].to
var rootfs = vm.mounts[0]
rootfs.destination = Self.guestRootfsPath(self.id)
try await agent.mount(rootfs)
try await agent.mount(
.init(
type: rootfs.type,
source: rootfs.source,
destination: rootfs.destination,
options: rootfs.options
))

// Handle file bind mounts for virtiofs shares
for (originalMount, attachedMount) in zip(self.config.mounts, vm.mounts.dropFirst()) where attachedMount.isFileBind {
let filename = URL(fileURLWithPath: originalMount.source).lastPathComponent
let sharedFilePath = "/\(attachedMount.source)/\(filename)"

try await agent.mount(
.init(
type: "none",
source: sharedFilePath,
destination: attachedMount.destination,
options: ["bind"] + (attachedMount.options.contains("ro") ? ["ro"] : [])
))
}

// Start up our friendly unix socket relays.
for socket in self.config.sockets {
Expand Down Expand Up @@ -449,7 +469,9 @@ extension LinuxContainer {
do {
var spec = generateRuntimeSpec()
// We don't need the rootfs, nor do OCI runtimes want it included.
spec.mounts = vm.mounts.dropFirst().map { $0.to }
spec.mounts = vm.mounts.dropFirst().map {
.init(type: $0.type, source: $0.source, destination: $0.destination, options: $0.options)
}

let stdio = Self.setupIO(
portAllocator: self.hostVsockPorts,
Expand Down
27 changes: 24 additions & 3 deletions Sources/Containerization/Mount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ 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
}

func configure(config: inout VZVirtualMachineConfiguration) throws {
switch self.runtimeOptions {
case .virtioblk(let options):
Expand All @@ -140,11 +154,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 = parentDirectory
} 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
71 changes: 71 additions & 0 deletions Tests/ContainerizationTests/MountTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
//
// 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.
//===----------------------------------------------------------------------===//

import Foundation
import Testing

@testable import Containerization

final class MountTests {
@Test func fileDetection() throws {
let tempDir = FileManager.default.temporaryDirectory
let testFile = tempDir.appendingPathComponent("testfile.txt")

try "test content".write(to: testFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: testFile) }

let mount = Mount.share(
source: testFile.path,
destination: "/app/config.txt"
)

#expect(mount.isFile == true)
#expect(mount.filename == "testfile.txt")
#expect(mount.parentDirectory == tempDir.path)
}

@Test func directoryDetection() throws {
let tempDir = FileManager.default.temporaryDirectory

let mount = Mount.share(
source: tempDir.path,
destination: "/app/data"
)

#expect(mount.isFile == false)
}

#if os(macOS)
@Test func attachedFilesystemBindFlag() throws {
let tempDir = FileManager.default.temporaryDirectory
let testFile = tempDir.appendingPathComponent("bindtest.txt")

try "bind test".write(to: testFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: testFile) }

let mount = Mount.share(
source: testFile.path,
destination: "/app/config.txt"
)

let allocator = Character.blockDeviceTagAllocator()
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)

#expect(attached.isFileBind == true)
#expect(attached.type == "virtiofs")
}
#endif
}