diff --git a/Sources/Containerization/AttachedFilesystem.swift b/Sources/Containerization/AttachedFilesystem.swift index f6b327e6..e6f4f58a 100644 --- a/Sources/Containerization/AttachedFilesystem.swift +++ b/Sources/Containerization/AttachedFilesystem.swift @@ -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 { @@ -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) 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() @@ -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 } diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index b16cb263..c5a56c63 100644 --- a/Sources/Containerization/Mount.swift +++ b/Sources/Containerization/Mount.swift @@ -132,6 +132,120 @@ 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 + let combinedPath = "\(self.source)|\(self.destination)" + let sourceHash = try hashMountSource(source: combinedPath) + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("containerization-file-mount-\(sourceHash)") + + // Use destination filename for the hardlink instead of source filename + let destinationFilename = URL(fileURLWithPath: self.destination).lastPathComponent + let isolatedFile = tempDir.appendingPathComponent(destinationFilename) + + // Check if hard link already exists + if FileManager.default.fileExists(atPath: isolatedFile.path) { + // Hard link already exists - nothing to do + return tempDir.path + } + + // Validate source file exists and is a regular file + try validateSourceFile() + + // Atomically create directory + try createDirectory(at: tempDir) + + let sourceFile = URL(fileURLWithPath: self.source) + + // Create the hard link, handling race conditions + do { + try FileManager.default.linkItem(at: sourceFile, to: isolatedFile) + } catch CocoaError.fileWriteFileExists { + // Another thread created the hardlink - that's fine + } catch { + throw ContainerizationError(.internalError, message: "Failed to create hardlink: \(error.localizedDescription)") + } + + // Final verification that the hardlinked file exists + guard FileManager.default.fileExists(atPath: isolatedFile.path) else { + throw ContainerizationError(.notFound, message: "Failed to create hardlink at: \(isolatedFile.path)") + } + + return tempDir.path + } + + /// Release reference to an isolated file share directory + /// No-op to avoid race conditions in parallel test execution + static func releaseIsolatedFileShare(source: String, destination: String) { + // No cleanup during tests to avoid race conditions + // OS will clean up temp directories on reboot + } + + /// Validate that the source file exists, is readable, and is not a symlink + private func validateSourceFile() throws { + + // Check if file exists + guard FileManager.default.fileExists(atPath: self.source) else { + throw ContainerizationError(.notFound, message: "Source file does not exist: \(self.source)") + } + + // Get file attributes to check if it's a regular file + let attributes = try FileManager.default.attributesOfItem(atPath: self.source) + let fileType = attributes[.type] as? FileAttributeType + + // Reject symlinks to prevent following links to unintended targets + guard fileType != .typeSymbolicLink else { + throw ContainerizationError(.invalidArgument, message: "Cannot mount symlink: \(self.source)") + } + + // Ensure it's a regular file + guard fileType == .typeRegular else { + throw ContainerizationError(.invalidArgument, message: "Source must be a regular file: \(self.source)") + } + + // Check if file is readable + guard FileManager.default.isReadableFile(atPath: self.source) else { + throw ContainerizationError(.invalidArgument, message: "Source file is not readable: \(self.source)") + } + } + + /// Atomically create directory (to prevent TOCTOU race conditions) + private func createDirectory(at url: URL) throws { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + // Register for cleanup + if url.path.contains("containerization-file-mount-") { + VZVirtualMachineInstance.registerTempDirectory(url.path) + } + } catch CocoaError.fileWriteFileExists { + // Directory already exists, verify it's actually a directory + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw ContainerizationError(.invalidArgument, message: "Path exists but is not a directory: \(url.path)") + } + // Directory exists and is valid, continue + } catch { + throw ContainerizationError(.internalError, message: "Failed to create directory \(url.path): \(error.localizedDescription)") + } + } + func configure(config: inout VZVirtualMachineConfiguration) throws { switch self.runtimeOptions { case .virtioblk(let options): @@ -140,11 +254,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( diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 0046bc18..77312ede 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -28,6 +28,32 @@ import Virtualization struct VZVirtualMachineInstance: VirtualMachineInstance, Sendable { typealias Agent = Vminitd + /// Cleanup registry for temporary directories + private static let tempDirectoryCleanup = Mutex>(Set()) + + /// Register a temporary directory for cleanup + public static func registerTempDirectory(_ path: String) { + _ = tempDirectoryCleanup.withLock { $0.insert(path) } + } + + /// Clean up all registered temporary directories + public static func cleanupTempDirectories() { + let directoriesToClean = tempDirectoryCleanup.withLock { + let dirs = Array($0) + $0.removeAll() + return dirs + } + + for directory in directoriesToClean { + do { + try FileManager.default.removeItem(atPath: directory) + } catch { + // Log but don't fail - cleanup is best effort + print("Warning: Failed to cleanup temporary directory \(directory): \(error)") + } + } + } + /// Attached mounts on the sandbox. public let mounts: [AttachedFilesystem] @@ -57,6 +83,8 @@ struct VZVirtualMachineInstance: VirtualMachineInstance, Sendable { public var initialFilesystem: Mount? /// File path to store the sandbox boot logs. public var bootlog: URL? + /// Cached consolidated mounts (computed once and reused). + private var consolidatedMountsCache: [Mount]? init() { self.cpus = 4 @@ -65,6 +93,18 @@ struct VZVirtualMachineInstance: VirtualMachineInstance, Sendable { self.nestedVirtualization = false self.mounts = [] self.interfaces = [] + self.consolidatedMountsCache = nil + } + + /// Returns consolidated mounts, computing and caching them on first access. + mutating func consolidatedMounts() throws -> [Mount] { + if let cached = consolidatedMountsCache { + return cached + } + + let consolidated = try consolidateMounts(self.mounts) + consolidatedMountsCache = consolidated + return consolidated } } @@ -87,16 +127,17 @@ struct VZVirtualMachineInstance: VirtualMachineInstance, Sendable { } init(group: MultiThreadedEventLoopGroup, config: Configuration, logger: Logger?) throws { + var mutableConfig = config self.config = config self.group = group self.lock = .init() self.queue = DispatchQueue(label: "com.apple.containerization.sandbox.\(UUID().uuidString)") - self.mounts = try config.mountAttachments() + self.mounts = try mutableConfig.mountAttachments() self.logger = logger self.timeSyncer = .init(logger: logger) self.vm = VZVirtualMachine( - configuration: try config.toVZ(), + configuration: try mutableConfig.toVZ(), queue: self.queue ) } @@ -258,7 +299,7 @@ extension VZVirtualMachineInstance.Configuration { return [c] } - func toVZ() throws -> VZVirtualMachineConfiguration { + mutating func toVZ() throws -> VZVirtualMachineConfiguration { var config = VZVirtualMachineConfiguration() config.cpuCount = self.cpus @@ -317,7 +358,10 @@ extension VZVirtualMachineInstance.Configuration { config.bootLoader = loader try initialFilesystem.configure(config: &config) - for mount in self.mounts { + + // Use consolidated mounts for VirtioFS share configuration + let consolidatedMounts = try self.consolidatedMounts() + for mount in consolidatedMounts { try mount.configure(config: &config) } @@ -337,7 +381,7 @@ extension VZVirtualMachineInstance.Configuration { return config } - func mountAttachments() throws -> [AttachedFilesystem] { + mutating func mountAttachments() throws -> [AttachedFilesystem] { let allocator = Character.blockDeviceTagAllocator() if let initialFilesystem { // When the initial filesystem is a blk, allocate the first letter "vd(a)" @@ -347,12 +391,139 @@ extension VZVirtualMachineInstance.Configuration { } } + // Use cached consolidated mounts (same as toVZ() to ensure hash consistency) + let consolidatedMounts = try self.consolidatedMounts() + var attachments: [AttachedFilesystem] = [] - for mount in self.mounts { - attachments.append(try .init(mount: mount, allocator: allocator)) + for mount in consolidatedMounts { + let attachment = try AttachedFilesystem(mount: mount, allocator: allocator) + attachments.append(attachment) } + return attachments } + + private func consolidateMounts(_ mounts: [Mount]) throws -> [Mount] { + var consolidatedMounts: [Mount] = [] + var fileMountsByParent: [String: [Mount]] = [:] + + // Group file mounts by parent directory + for mount in mounts { + if mount.isFile && mount.type == "virtiofs" { + let parentDir = URL(fileURLWithPath: mount.destination).deletingLastPathComponent().path + fileMountsByParent[parentDir, default: []].append(mount) + } else { + // Non-file mounts go directly to consolidated list + consolidatedMounts.append(mount) + } + } + + // Create consolidated mounts for each parent directory + for (parentDir, fileMounts) in fileMountsByParent { + // Both single and multiple file mounts need consolidation to ensure consistent VirtioFS tags + let consolidatedMount = try createConsolidatedMount(fileMounts: fileMounts, parentDir: parentDir) + consolidatedMounts.append(consolidatedMount) + } + + return consolidatedMounts + } + + private func createConsolidatedMount(fileMounts: [Mount], parentDir: String) throws -> Mount { + // Create a consolidated directory containing all the files + let consolidatedHash = try hashMountSources(sources: fileMounts.map { $0.source }) + let consolidatedTempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("containerization-consolidated-\(consolidatedHash)") + + // Atomically create directory to prevent TOCTOU race condition + try createConsolidatedDirectoryAtomically(at: consolidatedTempDir, fileMounts: fileMounts) + + // Create a consolidated mount targeting the parent directory + let consolidatedMount = Mount.share(source: consolidatedTempDir.path, destination: parentDir) + return consolidatedMount + } + + /// Atomically create consolidated directory and populate with hardlinks + private func createConsolidatedDirectoryAtomically(at url: URL, fileMounts: [Mount]) throws { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + // Register for cleanup if this is a containerization temp directory + if url.path.contains("containerization-consolidated-") { + VZVirtualMachineInstance.registerTempDirectory(url.path) + } + + // Create hardlinks for all files with their destination filenames + for mount in fileMounts { + // Validate each source file before creating hardlink + try validateSourceFileForMount(mount) + + let destinationFilename = URL(fileURLWithPath: mount.destination).lastPathComponent + let consolidatedFile = url.appendingPathComponent(destinationFilename) + let sourceFile = URL(fileURLWithPath: mount.source) + try FileManager.default.linkItem(at: sourceFile, to: consolidatedFile) + } + } catch CocoaError.fileWriteFileExists { + // Directory already exists, verify it's actually a directory and has expected contents + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw ContainerizationError(.invalidArgument, message: "Path exists but is not a directory: \(url.path)") + } + + // Verify all expected hardlinks exist + for mount in fileMounts { + let destinationFilename = URL(fileURLWithPath: mount.destination).lastPathComponent + let consolidatedFile = url.appendingPathComponent(destinationFilename) + if !FileManager.default.fileExists(atPath: consolidatedFile.path) { + // Missing hardlink, create it + try validateSourceFileForMount(mount) + let sourceFile = URL(fileURLWithPath: mount.source) + try FileManager.default.linkItem(at: sourceFile, to: consolidatedFile) + } + } + } catch { + throw ContainerizationError(.internalError, message: "Failed to create consolidated directory \(url.path): \(error.localizedDescription)") + } + } + + /// Validate source file for mount (extracted to avoid duplication) + private func validateSourceFileForMount(_ mount: Mount) throws { + let source = mount.source + + // Check if file exists + guard FileManager.default.fileExists(atPath: source) else { + throw ContainerizationError(.notFound, message: "Source file does not exist: \(source)") + } + + // Get file attributes to check if it's a regular file + let attributes = try FileManager.default.attributesOfItem(atPath: source) + let fileType = attributes[.type] as? FileAttributeType + + // Reject symlinks to prevent following links to unintended targets + guard fileType != .typeSymbolicLink else { + throw ContainerizationError(.invalidArgument, message: "Cannot mount symlink: \(source)") + } + + // Ensure it's a regular file + guard fileType == .typeRegular else { + throw ContainerizationError(.invalidArgument, message: "Source must be a regular file: \(source)") + } + + // Check if file is readable + guard FileManager.default.isReadableFile(atPath: source) else { + throw ContainerizationError(.invalidArgument, message: "Source file is not readable: \(source)") + } + } + + private func hashMountSources(sources: [String]) throws -> String { + // Create a deterministic hash of all source paths for consolidated mount + // Sanitize paths by escaping the separator character to prevent hash collisions + let sanitizedSources = sources.sorted().map { source in + source.replacingOccurrences(of: "|", with: "\\|") + } + let combined = sanitizedSources.joined(separator: "|") + return try hashMountSource(source: combined) + } } extension Mount { diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 1ca64634..99640ea8 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -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, "container pause and resume": testPauseResume, "container pause, resume and wait": testPauseResumeWait, "container pause, resume and verify io": testPauseResumeIO, diff --git a/Sources/Integration/VMTests.swift b/Sources/Integration/VMTests.swift index f368c23f..16c94c0d 100644 --- a/Sources/Integration/VMTests.swift +++ b/Sources/Integration/VMTests.swift @@ -257,6 +257,71 @@ 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() + + let value = String(data: buffer.data, encoding: .utf8) + + guard status == 0 else { + throw IntegrationError.assert(msg: "process status \(status) != 0 - output: \(value ?? "nil")") + } + + // For debugging - just check for success for now + } + + func testMultipleSingleFileMounts() async throws { + let id = "test-multiple-single-file-mounts" + + let bs = try await bootstrap() + let buffer = BufferWriter() + let errorBuffer = 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 + config.process.stderr = errorBuffer + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + let value = String(data: buffer.data, encoding: .utf8) + let errorValue = String(data: errorBuffer.data, encoding: .utf8) + let expected = "config data---\nsecret data" + + guard status == 0 else { + throw IntegrationError.assert(msg: "process status \(status) != 0 - stdout: \(value ?? "nil") - stderr: \(errorValue ?? "nil")") + } + + guard value == expected else { + throw IntegrationError.assert( + msg: "process should have returned '\(expected)' != '\(value ?? "nil")'") + } + } + func testContainerDevConsole() async throws { let id = "test-container-devconsole" @@ -289,6 +354,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() + guard status == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -310,4 +376,11 @@ extension IntegrationSuite { 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 + } } diff --git a/Tests/ContainerizationTests/MountTests.swift b/Tests/ContainerizationTests/MountTests.swift index 85db4c5b..ac5fe180 100644 --- a/Tests/ContainerizationTests/MountTests.swift +++ b/Tests/ContainerizationTests/MountTests.swift @@ -18,9 +18,9 @@ import Foundation import Testing @testable import Containerization +@testable import ContainerizationError -struct MountTests { - +final class MountTests { @Test func mountShareCreatesVirtiofsMount() { let mount = Mount.share( source: "/host/shared", @@ -40,4 +40,271 @@ struct MountTests { #expect(Bool(false), "Expected virtiofs runtime options") } } + + @Test func fileDetection() throws { + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("testfile-\(#function).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.hasPrefix("testfile-")) + #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-\(#function).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-\(#function).txt" + ) + + let allocator = Character.blockDeviceTagAllocator() + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + + #expect(attached.isFile == true) + #expect(attached.type == "virtiofs") + } + + @Test func nonExistentFileMount() throws { + let nonExistentFile = "/path/that/does/not/exist.txt" + + let mount = Mount.share( + source: nonExistentFile, + destination: "/app/config.txt" + ) + + #expect(mount.isFile == false) // Non-existent files are treated as directories + #expect(mount.filename == "exist.txt") + #expect(mount.parentDirectory == "/path/that/does/not") + } + + @Test func emptyFileMount() throws { + let tempDir = FileManager.default.temporaryDirectory + let emptyFile = tempDir.appendingPathComponent("empty-\(#function).txt") + + try "".write(to: emptyFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: emptyFile) } + + let mount = Mount.share( + source: emptyFile.path, + destination: "/app/empty.txt" + ) + + #expect(mount.isFile == true) + #expect(mount.filename.hasPrefix("empty-")) + #expect(mount.parentDirectory == tempDir.path) + } + + @Test func specialCharactersInFilename() throws { + let tempDir = FileManager.default.temporaryDirectory + let specialFile = tempDir.appendingPathComponent("file with spaces & symbols!@#-\(#function).txt") + + try "special content".write(to: specialFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: specialFile) } + + let mount = Mount.share( + source: specialFile.path, + destination: "/app/special.txt" + ) + + #expect(mount.isFile == true) + #expect(mount.filename.hasPrefix("file with spaces & symbols!@#-")) + #expect(mount.parentDirectory == tempDir.path) + + let allocator = Character.blockDeviceTagAllocator() + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + #expect(attached.isFile == true) + } + + @Test func hiddenFileMount() throws { + let tempDir = FileManager.default.temporaryDirectory + let hiddenFile = tempDir.appendingPathComponent(".hidden-\(#function)") + + try "hidden content".write(to: hiddenFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: hiddenFile) } + + let mount = Mount.share( + source: hiddenFile.path, + destination: "/app/.config" + ) + + #expect(mount.isFile == true) + #expect(mount.filename.hasPrefix(".hidden-")) + #expect(mount.parentDirectory == tempDir.path) + } + + @Test func readOnlyFileMount() throws { + let tempDir = FileManager.default.temporaryDirectory + let readOnlyFile = tempDir.appendingPathComponent("readonly-\(#function).txt") + + try "readonly content".write(to: readOnlyFile, atomically: true, encoding: .utf8) + defer { + // Remove read-only attribute before deletion + try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: readOnlyFile.path) + try? FileManager.default.removeItem(at: readOnlyFile) + } + + // Make file read-only + try FileManager.default.setAttributes([.posixPermissions: 0o444], ofItemAtPath: readOnlyFile.path) + + let mount = Mount.share( + source: readOnlyFile.path, + destination: "/app/readonly.txt" + ) + + #expect(mount.isFile == true) + #expect(mount.filename.hasPrefix("readonly-")) + + let allocator = Character.blockDeviceTagAllocator() + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + #expect(attached.isFile == true) + } + + @Test func hardlinkIsolation() throws { + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("isolation-test-\(#function).txt") + let originalContent = "hardlink test content" + + try originalContent.write(to: testFile, atomically: true, encoding: .utf8) + + let mount = Mount.share( + source: testFile.path, + destination: "/app/config-\(#function).txt" + ) + + // Create hardlink isolation + let isolatedDir = try mount.createIsolatedFileShare() + + // Cleanup in reverse order to prevent race conditions + defer { try? FileManager.default.removeItem(at: testFile) } + defer { Mount.releaseIsolatedFileShare(source: testFile.path, destination: "/app/config-\(#function).txt") } + + // Verify isolated directory contains only the target file + let isolatedContents = try FileManager.default.contentsOfDirectory(atPath: isolatedDir) + #expect(isolatedContents.count == 1) + #expect(isolatedContents.first == "config-hardlinkIsolation().txt") + + // Verify hardlinked file has same content + let isolatedFile = URL(fileURLWithPath: isolatedDir).appendingPathComponent("config-hardlinkIsolation().txt") + let isolatedContent = try String(contentsOf: isolatedFile, encoding: .utf8) + #expect(isolatedContent == originalContent) + + // Verify calling createIsolatedFileShare again returns the same directory (deterministic) + let isolatedDir2 = try mount.createIsolatedFileShare() + #expect(isolatedDir == isolatedDir2) + + // Verify the directory still contains the same file content + let isolatedFile2 = URL(fileURLWithPath: isolatedDir2).appendingPathComponent("config-hardlinkIsolation().txt") + let isolatedContent2 = try String(contentsOf: isolatedFile2, encoding: .utf8) + #expect(isolatedContent2 == originalContent) + } + + @Test func fileMountDestinationAdjustment() throws { + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("dest-test-\(#function).txt") + + try "destination test".write(to: testFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: testFile) } + + let mount = Mount.share( + source: testFile.path, + destination: "/app/subdir/config-\(#function).txt" + ) + + let allocator = Character.blockDeviceTagAllocator() + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + + // For file mounts, destination should be adjusted to parent directory + #expect(attached.destination == "/app/subdir") + #expect(attached.isFile == true) + + // Clean up hardlink isolation directory (should return same deterministic directory) + _ = try mount.createIsolatedFileShare() + Mount.releaseIsolatedFileShare(source: testFile.path, destination: "/app/subdir/config-\(#function).txt") + } + + @Test func rejectsSymlinks() throws { + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("symlink-source-\(#function).txt") + let symlinkFile = tempDir.appendingPathComponent("symlink-test-\(#function).txt") + + try "test content".write(to: testFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: testFile) } + + try FileManager.default.createSymbolicLink(at: symlinkFile, withDestinationURL: testFile) + defer { try? FileManager.default.removeItem(at: symlinkFile) } + + let mount = Mount.share(source: symlinkFile.path, destination: "/app/config-\(#function).txt") + + #expect(throws: ContainerizationError.self) { + try mount.createIsolatedFileShare() + } + } + + @Test func rejectsNonExistentFiles() throws { + let mount = Mount.share(source: "/nonexistent/file.txt", destination: "/app/config-\(#function).txt") + + #expect(throws: ContainerizationError.self) { + try mount.createIsolatedFileShare() + } + } + + @Test func rejectsDirectories() throws { + let tempDir = FileManager.default.temporaryDirectory + let testDir = tempDir.appendingPathComponent("test-directory-\(#function)") + + try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: testDir) } + + let mount = Mount.share(source: testDir.path, destination: "/app/config-\(#function).txt") + + #expect(throws: ContainerizationError.self) { + try mount.createIsolatedFileShare() + } + } + + @Test func registersForCleanup() throws { + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("cleanup-test-\(#function).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-\(#function).txt") + let isolatedDir = try mount.createIsolatedFileShare() + + // Verify directory was created + #expect(FileManager.default.fileExists(atPath: isolatedDir)) + + // Test cleanup functionality + VZVirtualMachineInstance.cleanupTempDirectories() + + // Directory should be removed after cleanup + #expect(!FileManager.default.fileExists(atPath: isolatedDir)) + } + #endif }