diff --git a/Sources/LucaCLI/Commands/InstallCommand.swift b/Sources/LucaCLI/Commands/InstallCommand.swift index 0574aad..ae9b5f6 100644 --- a/Sources/LucaCLI/Commands/InstallCommand.swift +++ b/Sources/LucaCLI/Commands/InstallCommand.swift @@ -160,7 +160,18 @@ struct InstallCommand: AsyncParsableCommand { """ )) var ignoreArchCheck: Bool = false - + + @Flag(help: ArgumentHelp( + "Skip unsafe archive entry validation.", + discussion: """ + Bypasses the Zip Slip / symlink-escape safety check. Use only when you trust the archive. + Example: + luca install some/tool@v1.0.0 \\ + --ignore-unsafe-entries + """ + )) + var ignoreUnsafeEntries: Bool = false + @Flag(inversion: .prefixedNo, help: ArgumentHelp( "Install the post-checkout git hook.", discussion: """ @@ -271,6 +282,7 @@ struct InstallCommand: AsyncParsableCommand { let installer = Installer( fileManager: fileManager, ignoreArchitectureCheck: ignoreArchCheck, + ignoreUnsafeArchiveEntries: ignoreUnsafeEntries, quiet: quiet, printer: printer, noora: noora diff --git a/Sources/LucaFoundation/Models/Tool.swift b/Sources/LucaFoundation/Models/Tool.swift index c2dc5d5..49b2579 100644 --- a/Sources/LucaFoundation/Models/Tool.swift +++ b/Sources/LucaFoundation/Models/Tool.swift @@ -31,6 +31,7 @@ import Foundation /// - ``checksum`` /// - ``algorithm`` /// - ``ignoreArchCheck`` +/// - ``ignoreUnsafeArchiveEntries`` /// /// ### Computed Properties /// - ``expectedBinaryName`` @@ -53,6 +54,9 @@ public struct Tool: Codable { /// Per-tool override for architecture validation. /// `true` always skips; `false` always validates; `nil` falls back to the CLI `--ignore-arch-check` flag. public let ignoreArchCheck: Bool? + /// Per-tool override for unsafe archive entry validation. + /// `true` always skips; `false` always validates; `nil` falls back to the CLI `--ignore-unsafe-entries` flag. + public let ignoreUnsafeArchiveEntries: Bool? public init( name: String, @@ -62,7 +66,8 @@ public struct Tool: Codable { desiredBinaryName: String? = nil, checksum: String? = nil, algorithm: ChecksumAlgorithm? = nil, - ignoreArchCheck: Bool? = nil + ignoreArchCheck: Bool? = nil, + ignoreUnsafeArchiveEntries: Bool? = nil ) { self.name = name self.version = version @@ -72,6 +77,7 @@ public struct Tool: Codable { self.checksum = checksum self.algorithm = algorithm self.ignoreArchCheck = ignoreArchCheck + self.ignoreUnsafeArchiveEntries = ignoreUnsafeArchiveEntries } } diff --git a/Sources/ManagerCore/Core/Installer/Installer.swift b/Sources/ManagerCore/Core/Installer/Installer.swift index a339c04..93ec4c6 100644 --- a/Sources/ManagerCore/Core/Installer/Installer.swift +++ b/Sources/ManagerCore/Core/Installer/Installer.swift @@ -49,6 +49,7 @@ public struct Installer { private let linkedToolsLister: LinkedToolsLister private let unlinker: Unlinker private let ignoreArchitectureCheck: Bool + private let ignoreUnsafeArchiveEntries: Bool private let quiet: Bool private let noora: Noorable private let toolInstaller: ToolInstalling @@ -60,6 +61,7 @@ public struct Installer { public init( fileManager: FileManaging, ignoreArchitectureCheck: Bool, + ignoreUnsafeArchiveEntries: Bool = false, quiet: Bool = false, printer: Printing, noora: Noorable @@ -67,6 +69,7 @@ public struct Installer { self.init( fileManager: fileManager, ignoreArchitectureCheck: ignoreArchitectureCheck, + ignoreUnsafeArchiveEntries: ignoreUnsafeArchiveEntries, quiet: quiet, printer: printer, noora: noora, @@ -81,6 +84,7 @@ public struct Installer { init( fileManager: FileManaging, ignoreArchitectureCheck: Bool, + ignoreUnsafeArchiveEntries: Bool = false, quiet: Bool = false, printer: Printing, noora: Noorable = Noora(), @@ -95,11 +99,13 @@ public struct Installer { self.linkedToolsLister = LinkedToolsLister(fileManager: fileManager) self.unlinker = Unlinker(fileManager: fileManager, printer: printer) self.ignoreArchitectureCheck = ignoreArchitectureCheck + self.ignoreUnsafeArchiveEntries = ignoreUnsafeArchiveEntries self.quiet = quiet self.noora = noora self.toolInstaller = toolInstaller ?? ToolInstaller( fileManager: fileManager, ignoreArchitectureCheck: ignoreArchitectureCheck, + ignoreUnsafeArchiveEntries: ignoreUnsafeArchiveEntries, printer: printer ) self.skillInstaller = skillInstaller ?? SkillInstaller() diff --git a/Sources/ManagerCore/Core/ToolFactory/ToolFactory.swift b/Sources/ManagerCore/Core/ToolFactory/ToolFactory.swift index 11d276a..bc7737d 100644 --- a/Sources/ManagerCore/Core/ToolFactory/ToolFactory.swift +++ b/Sources/ManagerCore/Core/ToolFactory/ToolFactory.swift @@ -55,7 +55,8 @@ struct ToolFactory { desiredBinaryName: desiredBinaryName, checksum: checksum, algorithm: algorithm, - ignoreArchCheck: nil + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil )] } } @@ -97,7 +98,8 @@ struct ToolFactory { desiredBinaryName: desiredBinaryName, checksum: checksum, algorithm: algorithm, - ignoreArchCheck: nil + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil ) } } diff --git a/Sources/ManagerCore/Core/ToolInstaller/ToolInstaller.swift b/Sources/ManagerCore/Core/ToolInstaller/ToolInstaller.swift index 8c3b240..3036c88 100644 --- a/Sources/ManagerCore/Core/ToolInstaller/ToolInstaller.swift +++ b/Sources/ManagerCore/Core/ToolInstaller/ToolInstaller.swift @@ -34,15 +34,18 @@ struct ToolInstaller: ToolInstalling { private let symLinker: SymLinking private let printer: Printing private let ignoreArchitectureCheck: Bool + private let ignoreUnsafeArchiveEntries: Bool init( fileManager: FileManaging, ignoreArchitectureCheck: Bool, + ignoreUnsafeArchiveEntries: Bool = false, printer: Printing, downloader: Downloading? = nil ) { self.fileManager = fileManager self.ignoreArchitectureCheck = ignoreArchitectureCheck + self.ignoreUnsafeArchiveEntries = ignoreUnsafeArchiveEntries self.printer = printer self.binaryFinder = BinaryFinder(fileManager: fileManager) self.checksumValidator = ChecksumValidator(fileManager: fileManager) @@ -120,7 +123,11 @@ struct ToolInstaller: ToolInstalling { let fileTypeDetector = FileTypeDetector(fileManager: fileManager) - let unarchiver = Unarchiver(fileManager: fileManager, fileTypeDetector: fileTypeDetector) + let effectiveIgnoreUnsafeEntries = tool.ignoreUnsafeArchiveEntries ?? ignoreUnsafeArchiveEntries + if effectiveIgnoreUnsafeEntries { + printer.printFormatted("\(.raw("🔍 Skipping unsafe archive entry validation for \(tool.name) version \(tool.version)..."))") + } + let unarchiver = Unarchiver(fileManager: fileManager, fileTypeDetector: fileTypeDetector, ignoreUnsafeArchiveEntries: effectiveIgnoreUnsafeEntries) try unarchiver.unarchive(filePath: downloadedFile, installationDestination: installationDestination) let extractedBinaryPath: String = try { diff --git a/Sources/ManagerCore/Core/Unarchiver/Unarchiver.swift b/Sources/ManagerCore/Core/Unarchiver/Unarchiver.swift index c0340c1..84ec3ea 100644 --- a/Sources/ManagerCore/Core/Unarchiver/Unarchiver.swift +++ b/Sources/ManagerCore/Core/Unarchiver/Unarchiver.swift @@ -22,7 +22,8 @@ struct Unarchiver: Unarchiving { case unrecognizedFileType(String) case notAnArchive(String) case failedToUnarchive(Error) - + case unsafeArchiveEntry(String) + var errorDescription: String? { switch self { case .unrecognizedFileType(let file): @@ -31,16 +32,20 @@ struct Unarchiver: Unarchiving { return "File \(file) is not an archive." case .failedToUnarchive(let error): return "Failed to unarchive with error '\(error)'." + case .unsafeArchiveEntry(let entry): + return "Refusing to extract archive: it contains an unsafe entry ('\(entry)') that could write outside the installation directory." } } } private let fileManager: UnarchiverFileManaging private let fileTypeDetector: FileTypeDetector - - init(fileManager: UnarchiverFileManaging, fileTypeDetector: FileTypeDetector) { + private let ignoreUnsafeArchiveEntries: Bool + + init(fileManager: UnarchiverFileManaging, fileTypeDetector: FileTypeDetector, ignoreUnsafeArchiveEntries: Bool = false) { self.fileManager = fileManager self.fileTypeDetector = fileTypeDetector + self.ignoreUnsafeArchiveEntries = ignoreUnsafeArchiveEntries } /// Extracts an archive to the specified destination. @@ -55,13 +60,27 @@ struct Unarchiver: Unarchiving { func unarchive(filePath: URL, installationDestination: URL) throws { try fileManager.createDirectory(at: installationDestination, withIntermediateDirectories: true) - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - guard let fileType = try fileTypeDetector.detectFileType(at: filePath) else { throw UnarchiverError.unrecognizedFileType(filePath.path) } - + + if case .executable = fileType { + throw UnarchiverError.notAnArchive(filePath.path) + } + + if !ignoreUnsafeArchiveEntries { + // Reject archives whose contents could escape the installation directory before + // extracting anything. `unzip` (and, on some platforms, `tar`) will happily create a + // symbolic link and then write *through* it, letting a crafted archive drop files into + // arbitrary locations such as `~/.ssh` or shell start-up files (the "Zip Slip" / tar + // symlink-escape class of attacks). Validating up front means a malicious archive never + // reaches the extraction step. + try validateArchiveEntries(filePath: filePath, fileType: fileType) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + switch fileType { case .zip: process.arguments = ["unzip", "-q", "-o", filePath.path, "-d", installationDestination.path] @@ -90,4 +109,94 @@ struct Unarchiver: Unarchiving { throw UnarchiverError.failedToUnarchive(error) } } + + // MARK: - Safety Validation + + /// Inspects an archive's table of contents and throws if any entry could escape the + /// installation directory. + /// + /// An entry is rejected when it is a symbolic or hard link, uses an absolute path, or + /// contains a `..` path component. + /// + /// - Parameters: + /// - filePath: The archive to inspect. + /// - fileType: The detected archive type (``FileType/zip`` or ``FileType/targz``). + /// - Throws: ``UnarchiverError/unsafeArchiveEntry(_:)`` for an unsafe entry, or + /// ``UnarchiverError/failedToUnarchive(_:)`` if the archive cannot be listed. + private func validateArchiveEntries(filePath: URL, fileType: FileType) throws { + let nameArguments: [String] + let verboseArguments: [String] + switch fileType { + case .zip: + nameArguments = ["unzip", "-Z1", filePath.path] + verboseArguments = ["unzip", "-Z", filePath.path] + case .targz: + nameArguments = ["tar", "-tzf", filePath.path] + verboseArguments = ["tar", "-tvzf", filePath.path] + case .executable: + return + } + + // Reject absolute paths and parent-directory traversal by entry name. + for rawName in try listOutput(arguments: nameArguments) { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + if name.hasPrefix("/") || name.hasPrefix("~") { + throw UnarchiverError.unsafeArchiveEntry(name) + } + if name.components(separatedBy: "/").contains("..") { + throw UnarchiverError.unsafeArchiveEntry(name) + } + } + + // Reject symbolic links ("l") and hard links ("h"). Both `unzip -Z` and `tar -tv` + // print a Unix-style mode column whose first character encodes the entry type. + for line in try listOutput(arguments: verboseArguments) { + guard let typeChar = line.drop(while: { $0 == " " }).first else { continue } + if typeChar == "l" || typeChar == "h" { + throw UnarchiverError.unsafeArchiveEntry(line.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + } + + /// Runs a listing command and returns its standard output split into lines. + /// + /// - Parameter arguments: The arguments passed to `/usr/bin/env`. + /// - Returns: The standard-output lines produced by the command. + /// - Throws: ``UnarchiverError/failedToUnarchive(_:)`` if the command cannot be run or + /// exits with a non-zero status (e.g. a corrupt archive). + private func listOutput(arguments: [String]) throws -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + throw UnarchiverError.failedToUnarchive(error) + } + + // Read before waiting to avoid deadlocking on a full pipe buffer. + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let errStr = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error" + let error = NSError( + domain: "io.github.luca.unarchiver", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: "Failed to list archive contents: \(errStr)"] + ) + throw UnarchiverError.failedToUnarchive(error) + } + + let output = String(data: data, encoding: .utf8) ?? "" + return output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + } } diff --git a/Tests/Core/ErrorDescriptionTests.swift b/Tests/Core/ErrorDescriptionTests.swift index 0b8f0c1..dee2fef 100644 --- a/Tests/Core/ErrorDescriptionTests.swift +++ b/Tests/Core/ErrorDescriptionTests.swift @@ -87,6 +87,7 @@ struct ErrorDescriptionTests { #expect(Unarchiver.UnarchiverError.unrecognizedFileType("/some/file").errorDescription != nil) #expect(Unarchiver.UnarchiverError.notAnArchive("/some/file").errorDescription != nil) #expect(Unarchiver.UnarchiverError.failedToUnarchive(underlyingError).errorDescription != nil) + #expect(Unarchiver.UnarchiverError.unsafeArchiveEntry("../escape.txt").errorDescription != nil) } @Test diff --git a/Tests/Core/InstallerTests.swift b/Tests/Core/InstallerTests.swift index 573a099..4ccbcab 100644 --- a/Tests/Core/InstallerTests.swift +++ b/Tests/Core/InstallerTests.swift @@ -753,6 +753,33 @@ struct InstallerTests { #expect(skillSymLinkerMock.lastAgents == AgentRegistry.all) } + @Test + func test_init_publicInit_ignoreUnsafeArchiveEntries_threadsThrough() async throws { + // Verify the public init correctly threads ignoreUnsafeArchiveEntries to the internal init. + // Uses a home-directory file manager so the install call throws early without any network I/O. + let homeDirFileManager = HomeDirFileManagerMock() + let installer = Installer( + fileManager: homeDirFileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: true, + printer: PrinterMock(), + noora: NoorableMock() + ) + await #expect(throws: Installer.InstallerError.runningFromHomeDirectory) { + try await installer.install( + installationType: .individualInline( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil + ) + ) + } + } + private func spec(for fixture: Fixture) throws -> Spec { let bundle = Bundle.module let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) diff --git a/Tests/Core/ToolInstallerTests.swift b/Tests/Core/ToolInstallerTests.swift index 056bac1..26e01f2 100644 --- a/Tests/Core/ToolInstallerTests.swift +++ b/Tests/Core/ToolInstallerTests.swift @@ -10,11 +10,13 @@ struct ToolInstallerTests { private func makeToolInstaller( fileManager: FileManaging, ignoreArchitectureCheck: Bool, + ignoreUnsafeArchiveEntries: Bool = false, downloader: Downloading ) -> ToolInstaller { ToolInstaller( fileManager: fileManager, ignoreArchitectureCheck: ignoreArchitectureCheck, + ignoreUnsafeArchiveEntries: ignoreUnsafeArchiveEntries, printer: PrinterMock(), downloader: downloader ) @@ -538,4 +540,192 @@ struct ToolInstallerTests { try await toolInstaller.install(tool: tool) } } + + // MARK: - ignoreUnsafeArchiveEntries override tests + + @Test + func test_install_globalIgnoreUnsafeArchiveEntries_true_skipsValidation() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: true, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil + ) + + var didThrowUnsafeEntryError = false + do { + try await toolInstaller.install(tool: tool) + } catch let e as Unarchiver.UnarchiverError { + if case .unsafeArchiveEntry = e { didThrowUnsafeEntryError = true } + } catch { } + #expect(!didThrowUnsafeEntryError) + } + + @Test + func test_install_globalIgnoreUnsafeArchiveEntries_false_enforcesValidation() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: false, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil + ) + + await #expect { + try await toolInstaller.install(tool: tool) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + } + + @Test + func test_install_perToolIgnoreUnsafeArchiveEntries_true_overridesGlobalFalse() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: false, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: true // per-tool true overrides global false + ) + + var didThrowUnsafeEntryError = false + do { + try await toolInstaller.install(tool: tool) + } catch let e as Unarchiver.UnarchiverError { + if case .unsafeArchiveEntry = e { didThrowUnsafeEntryError = true } + } catch { } + #expect(!didThrowUnsafeEntryError) + } + + @Test + func test_install_perToolIgnoreUnsafeArchiveEntries_false_overridesGlobalTrue() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: true, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: false // per-tool false overrides global true + ) + + await #expect { + try await toolInstaller.install(tool: tool) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + } + + @Test + func test_install_perToolIgnoreUnsafeArchiveEntries_nil_fallsBackToGlobal_true_skips() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: true, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil // nil falls back to global true + ) + + var didThrowUnsafeEntryError = false + do { + try await toolInstaller.install(tool: tool) + } catch let e as Unarchiver.UnarchiverError { + if case .unsafeArchiveEntry = e { didThrowUnsafeEntryError = true } + } catch { } + #expect(!didThrowUnsafeEntryError) + } + + @Test + func test_install_perToolIgnoreUnsafeArchiveEntries_nil_fallsBackToGlobal_false_enforces() async throws { + let fileManager = FileManagerWrapperMock() + let toolInstaller = makeToolInstaller( + fileManager: fileManager, + ignoreArchitectureCheck: true, + ignoreUnsafeArchiveEntries: false, + downloader: DownloaderMock(result: .fixture(Fixture(filename: "MockSymlink", type: "zip"))) + ) + + let tool = Tool( + name: "SomeTool", + version: "1.0.0", + url: URL(string: "https://example.com/tool")!, + binaryPath: nil, + desiredBinaryName: nil, + checksum: nil, + algorithm: nil, + ignoreArchCheck: nil, + ignoreUnsafeArchiveEntries: nil // nil falls back to global false + ) + + await #expect { + try await toolInstaller.install(tool: tool) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + } } diff --git a/Tests/Core/UnarchiverTests.swift b/Tests/Core/UnarchiverTests.swift index b597af5..1b5b0e2 100644 --- a/Tests/Core/UnarchiverTests.swift +++ b/Tests/Core/UnarchiverTests.swift @@ -81,6 +81,124 @@ struct UnarchiverTests { } } + @Test(arguments: ["zip", "tar.gz"]) + func unarchive_archiveContainingSymlink_throws(archiveType: String) throws { + let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) + let fileTypeDetectorFileManager = FileTypeDetectorFileManagerMock(fileManager: .default) + let fileTypeDetector = FileTypeDetector(fileManager: fileTypeDetectorFileManager) + let sut = Unarchiver(fileManager: unarchiverFileManager, fileTypeDetector: fileTypeDetector) + + let bundle = Bundle.module + let fixture = Fixture(filename: "MockSymlink", type: archiveType) + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let installationDestination = unarchiverFileManager.toolsFolder + .appending(components: "Tool", "1.0.0") + + #expect { + try sut.unarchive(filePath: URL(filePath: path), installationDestination: installationDestination) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + + // The malicious payload must not have been extracted. + #expect(!FileManager.default.fileExists(atPath: installationDestination.appending(component: "evil_link").path)) + } + + @Test + func unarchive_archiveWithParentTraversal_throws() throws { + let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) + let fileTypeDetectorFileManager = FileTypeDetectorFileManagerMock(fileManager: .default) + let fileTypeDetector = FileTypeDetector(fileManager: fileTypeDetectorFileManager) + let sut = Unarchiver(fileManager: unarchiverFileManager, fileTypeDetector: fileTypeDetector) + + let bundle = Bundle.module + let fixture = Fixture(filename: "MockTraversal", type: "tar.gz") + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let installationDestination = unarchiverFileManager.toolsFolder + .appending(components: "Tool", "1.0.0") + + #expect { + try sut.unarchive(filePath: URL(filePath: path), installationDestination: installationDestination) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + } + + @Test + func unarchive_archiveWithAbsolutePath_throws() throws { + let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) + let fileTypeDetectorFileManager = FileTypeDetectorFileManagerMock(fileManager: .default) + let fileTypeDetector = FileTypeDetector(fileManager: fileTypeDetectorFileManager) + let sut = Unarchiver(fileManager: unarchiverFileManager, fileTypeDetector: fileTypeDetector) + + let bundle = Bundle.module + let fixture = Fixture(filename: "MockAbsolutePath", type: "zip") + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let installationDestination = unarchiverFileManager.toolsFolder + .appending(components: "Tool", "1.0.0") + + #expect { + try sut.unarchive(filePath: URL(filePath: path), installationDestination: installationDestination) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .unsafeArchiveEntry = unarchiverError else { return false } + return true + } + + // The malicious payload must not have been extracted. + #expect(!FileManager.default.fileExists(atPath: installationDestination.appending(component: "evil.txt").path)) + } + + @Test(arguments: ["zip", "tar.gz"]) + func test_unarchive_archiveContainingSymlink_ignoringUnsafeEntries_succeeds(archiveType: String) throws { + let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) + let fileTypeDetectorFileManager = FileTypeDetectorFileManagerMock(fileManager: .default) + let fileTypeDetector = FileTypeDetector(fileManager: fileTypeDetectorFileManager) + let sut = Unarchiver(fileManager: unarchiverFileManager, fileTypeDetector: fileTypeDetector, ignoreUnsafeArchiveEntries: true) + + let bundle = Bundle.module + let fixture = Fixture(filename: "MockSymlink", type: archiveType) + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let installationDestination = unarchiverFileManager.toolsFolder + .appending(components: "Tool", "1.0.0") + + // Should not throw unsafeArchiveEntry when ignoreUnsafeArchiveEntries is true + try sut.unarchive(filePath: URL(filePath: path), installationDestination: installationDestination) + } + + @Test + func test_unarchive_archiveWithParentTraversal_ignoringUnsafeEntries_throwsFailedToUnarchiveNotUnsafeEntry() throws { + // Our pre-validation is bypassed, but `tar` itself also rejects `../` paths at extraction time. + // The error must be failedToUnarchive (from tar), not unsafeArchiveEntry (from our check). + let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) + let fileTypeDetectorFileManager = FileTypeDetectorFileManagerMock(fileManager: .default) + let fileTypeDetector = FileTypeDetector(fileManager: fileTypeDetectorFileManager) + let sut = Unarchiver(fileManager: unarchiverFileManager, fileTypeDetector: fileTypeDetector, ignoreUnsafeArchiveEntries: true) + + let bundle = Bundle.module + let fixture = Fixture(filename: "MockTraversal", type: "tar.gz") + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let installationDestination = unarchiverFileManager.toolsFolder + .appending(components: "Tool", "1.0.0") + + #expect { + try sut.unarchive(filePath: URL(filePath: path), installationDestination: installationDestination) + } throws: { error in + guard let unarchiverError = error as? Unarchiver.UnarchiverError, + case .failedToUnarchive = unarchiverError else { return false } + return true + } + } + @Test func unarchive_failedToUnarchive_throws() throws { let unarchiverFileManager = UnarchiverFileManagerMock(fileManager: .default) diff --git a/Tests/Fixtures/Archives/MockAbsolutePath.zip b/Tests/Fixtures/Archives/MockAbsolutePath.zip new file mode 100644 index 0000000..26bbca7 Binary files /dev/null and b/Tests/Fixtures/Archives/MockAbsolutePath.zip differ diff --git a/Tests/Fixtures/Archives/MockSymlink.tar.gz b/Tests/Fixtures/Archives/MockSymlink.tar.gz new file mode 100644 index 0000000..8eb62b0 Binary files /dev/null and b/Tests/Fixtures/Archives/MockSymlink.tar.gz differ diff --git a/Tests/Fixtures/Archives/MockSymlink.zip b/Tests/Fixtures/Archives/MockSymlink.zip new file mode 100644 index 0000000..fc3d72b Binary files /dev/null and b/Tests/Fixtures/Archives/MockSymlink.zip differ diff --git a/Tests/Fixtures/Archives/MockTraversal.tar.gz b/Tests/Fixtures/Archives/MockTraversal.tar.gz new file mode 100644 index 0000000..9eb0406 Binary files /dev/null and b/Tests/Fixtures/Archives/MockTraversal.tar.gz differ diff --git a/Tests/Fixtures/Lucafiles/Lucafile_mock_ignoreUnsafeArchiveEntries_true.yml b/Tests/Fixtures/Lucafiles/Lucafile_mock_ignoreUnsafeArchiveEntries_true.yml new file mode 100644 index 0000000..2334055 --- /dev/null +++ b/Tests/Fixtures/Lucafiles/Lucafile_mock_ignoreUnsafeArchiveEntries_true.yml @@ -0,0 +1,9 @@ +--- +tools: + - name: MockTool + binaryPath: MockMachOTool + version: 1.0.0 + url: https://example.com/mock/MockToolIgnoreUnsafeTrue + ignoreUnsafeArchiveEntries: true + +version: 0.0.1