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
14 changes: 13 additions & 1 deletion Sources/LucaCLI/Commands/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: """
Expand Down Expand Up @@ -271,6 +282,7 @@ struct InstallCommand: AsyncParsableCommand {
let installer = Installer(
fileManager: fileManager,
ignoreArchitectureCheck: ignoreArchCheck,
ignoreUnsafeArchiveEntries: ignoreUnsafeEntries,
quiet: quiet,
printer: printer,
noora: noora
Expand Down
8 changes: 7 additions & 1 deletion Sources/LucaFoundation/Models/Tool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import Foundation
/// - ``checksum``
/// - ``algorithm``
/// - ``ignoreArchCheck``
/// - ``ignoreUnsafeArchiveEntries``
///
/// ### Computed Properties
/// - ``expectedBinaryName``
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -72,6 +77,7 @@ public struct Tool: Codable {
self.checksum = checksum
self.algorithm = algorithm
self.ignoreArchCheck = ignoreArchCheck
self.ignoreUnsafeArchiveEntries = ignoreUnsafeArchiveEntries
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/ManagerCore/Core/Installer/Installer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,13 +61,15 @@ public struct Installer {
public init(
fileManager: FileManaging,
ignoreArchitectureCheck: Bool,
ignoreUnsafeArchiveEntries: Bool = false,
quiet: Bool = false,
printer: Printing,
noora: Noorable
) {
self.init(
fileManager: fileManager,
ignoreArchitectureCheck: ignoreArchitectureCheck,
ignoreUnsafeArchiveEntries: ignoreUnsafeArchiveEntries,
quiet: quiet,
printer: printer,
noora: noora,
Expand All @@ -81,6 +84,7 @@ public struct Installer {
init(
fileManager: FileManaging,
ignoreArchitectureCheck: Bool,
ignoreUnsafeArchiveEntries: Bool = false,
quiet: Bool = false,
printer: Printing,
noora: Noorable = Noora(),
Expand All @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions Sources/ManagerCore/Core/ToolFactory/ToolFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ struct ToolFactory {
desiredBinaryName: desiredBinaryName,
checksum: checksum,
algorithm: algorithm,
ignoreArchCheck: nil
ignoreArchCheck: nil,
ignoreUnsafeArchiveEntries: nil
)]
}
}
Expand Down Expand Up @@ -97,7 +98,8 @@ struct ToolFactory {
desiredBinaryName: desiredBinaryName,
checksum: checksum,
algorithm: algorithm,
ignoreArchCheck: nil
ignoreArchCheck: nil,
ignoreUnsafeArchiveEntries: nil
)
}
}
9 changes: 8 additions & 1 deletion Sources/ManagerCore/Core/ToolInstaller/ToolInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 116 additions & 7 deletions Sources/ManagerCore/Core/Unarchiver/Unarchiver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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]
Expand Down Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions Tests/Core/ErrorDescriptionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Tests/Core/InstallerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading