From fef3ddda28a6b09691b53ce61b4d50d0abee592a Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:58:55 +0100 Subject: [PATCH] feat: calculate and verify file checksums --- Config/products/macpacker.json | 24 ++ FinderExtension/FinderSync.swift | 18 +- MacPacker/AppDelegate.swift | 17 +- .../UrlHandling/AppUrlChecksumHandler.swift | 33 +++ .../Checksums/ChecksumWindowController.swift | 230 ++++++++++++++++++ .../Settings/IntegrationSettingsView.swift | 4 + MacPacker/Localizable.xcstrings | 48 ++++ .../Core/Checksums/FileChecksums.swift | 106 ++++++++ Modules/Sources/FinderMenu/AppUrl.swift | 4 +- .../Sources/FinderMenu/FinderMenuItem.swift | 31 ++- Modules/Tests/CoreTests/AppUrlTests.swift | 15 +- .../Tests/CoreTests/FileChecksumsTests.swift | 84 +++++++ .../CoreTests/FinderMenuSettingsTests.swift | 19 +- 13 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 MacPacker/Core/UrlHandling/AppUrlChecksumHandler.swift create mode 100644 MacPacker/Features/Checksums/ChecksumWindowController.swift create mode 100644 Modules/Sources/Core/Checksums/FileChecksums.swift create mode 100644 Modules/Tests/CoreTests/FileChecksumsTests.swift diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index a81e610c..3b6cb041 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -38,6 +38,30 @@ { "version": "1.0.1", "items": [ + { + "type": "feat", + "title": { + "en": "Calculate and verify file checksums", + "zh-Hans": "计算并验证文件校验和", + "fr": "Calculer et vérifier les sommes de contrôle des fichiers", + "de": "Dateiprüfsummen berechnen und überprüfen", + "it": "Calcola e verifica i checksum dei file", + "ja": "ファイルのチェックサムを計算して検証", + "fa": "محاسبه و بررسی جمع‌های کنترلی فایل‌ها", + "pl": "Obliczanie i weryfikacja sum kontrolnych plików", + "pt-BR": "Calcular e verificar somas de verificação de arquivos", + "ru": "Вычисление и проверка контрольных сумм файлов", + "uk": "Обчислення та перевірка контрольних сум файлів", + "es-MX": "Calcular y verificar sumas de comprobación de archivos", + "ko": "파일 체크섬 계산 및 검증", + "nl": "Bestandscontrolesommen berekenen en verifiëren", + "vi": "Tính và xác minh tổng kiểm tra tệp", + "tr": "Dosya sağlama toplamlarını hesapla ve doğrula" + }, + "issues": [ + "233" + ] + }, { "type": "fix", "highlight": true, diff --git a/FinderExtension/FinderSync.swift b/FinderExtension/FinderSync.swift index 64ad45da..973bd16b 100644 --- a/FinderExtension/FinderSync.swift +++ b/FinderExtension/FinderSync.swift @@ -186,6 +186,8 @@ class FinderSync: FIFinderSync { case .compressTo7z: #selector(compressTo7z(_:)) case .compressEachSeparately: #selector(compressEachSeparately(_:)) case .compressFolderContents: #selector(compressFolderContents(_:)) + case .checksums: #selector(checksums(_:)) + case .verifyChecksum: #selector(verifyChecksum(_:)) } } @@ -236,6 +238,12 @@ class FinderSync: FIFinderSync { case .compressFolderContents: let folder = allItems.first?.lastPathComponent ?? "" return String(localized: .commonCompressContentsTo(folder)) + + case .checksums: + return String(localized: "Checksums…", comment: "Finder action to calculate file checksums") + + case .verifyChecksum: + return String(localized: "Verify Checksum from Clipboard", comment: "Finder action to compare selected files with a copied checksum") } } @@ -357,5 +365,13 @@ class FinderSync: FIFinderSync { communicateWithMainApp(item: .compressFolderContents) } -} + @objc func checksums(_ sender: Any?) { + log.notice("Finder menu: Checksums") + communicateWithMainApp(item: .checksums) + } + @objc func verifyChecksum(_ sender: Any?) { + log.notice("Finder menu: Verify Checksum from Clipboard") + communicateWithMainApp(item: .verifyChecksum) + } +} diff --git a/MacPacker/AppDelegate.swift b/MacPacker/AppDelegate.swift index c94ade51..bf8b485b 100644 --- a/MacPacker/AppDelegate.swift +++ b/MacPacker/AppDelegate.swift @@ -107,6 +107,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { handler = AppUrlCompressEachHandler(catalog: appState.catalog, engineSelector: appState.engineSelector) case .compressContents: handler = AppUrlCompressContentsHandler(catalog: appState.catalog, engineSelector: appState.engineSelector) + case .checksums, .verifyChecksum: + handler = AppUrlChecksumHandler() } guard let handler else { @@ -184,10 +186,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let opensWindow = LaunchParameters.opensWindow #if DEBUG let showsExtractionDemo = ExtractionDemo.isRequested + let showsChecksumDemo = ProcessInfo.processInfo.environment["MACPACKER_DEBUG_CHECKSUM_FILE"] != nil #else let showsExtractionDemo = false + let showsChecksumDemo = false #endif - let launchedToOpenSomething = opensWindow || showsExtractionDemo + let launchedForChecksums = pendingOpenURLs.contains { url in + guard url.scheme == UrlParser.appScheme, + let action = UrlParser().parse(appUrl: url)?.action else { return false } + return action == .checksums || action == .verifyChecksum + } + let launchedToOpenSomething = opensWindow || showsExtractionDemo || launchedForChecksums || showsChecksumDemo // make sure that at least one window will be shown even if it is empty if !launchedToOpenSomething { @@ -219,6 +228,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { log.notice("applicationDidFinishLaunching done") #if DEBUG + // Repeatable UI check with a disposable file, without enabling a Finder + // extension or granting access to a personal folder. + if let path = ProcessInfo.processInfo.environment["MACPACKER_DEBUG_CHECKSUM_FILE"] { + ChecksumWindowController.show(files: [URL(fileURLWithPath: path)], verifyFromClipboard: false) + } + // Debug-only end-to-end hook: MACPACKER_DEBUG_EXTRACT="|" // opens the archive headless and extracts it fully — lets the // extraction progress window be exercised without UI scripting. diff --git a/MacPacker/Core/UrlHandling/AppUrlChecksumHandler.swift b/MacPacker/Core/UrlHandling/AppUrlChecksumHandler.swift new file mode 100644 index 00000000..9dffb3c6 --- /dev/null +++ b/MacPacker/Core/UrlHandling/AppUrlChecksumHandler.swift @@ -0,0 +1,33 @@ +// +// AppUrlChecksumHandler.swift +// MacPacker +// + +import Core +import FinderMenu +import Foundation +import tb + +private let log = tb.Logger(subsystem: "app.MacPacker", category: "url") + +/// Finder's file-checksum actions share one window; Verify starts with the +/// clipboard value, and Checksums starts with a blank comparison field. +class AppUrlChecksumHandler: AppUrlHandler { + func handle(appUrl: AppUrl, archiveWindowManager: ArchiveWindowManager) { + Task { @MainActor in + var checkedFolders: Set = [] + for file in appUrl.files { + let folder = file.deletingLastPathComponent() + guard checkedFolders.insert(folder).inserted else { continue } + guard await FolderAccessStore.shared.ensureAccess(forFolder: folder) else { + log.error("No access to \(folder.lastPathComponent) — cannot calculate checksums") + return + } + } + ChecksumWindowController.show( + files: appUrl.files, + verifyFromClipboard: appUrl.action == .verifyChecksum + ) + } + } +} diff --git a/MacPacker/Features/Checksums/ChecksumWindowController.swift b/MacPacker/Features/Checksums/ChecksumWindowController.swift new file mode 100644 index 00000000..3ea994f0 --- /dev/null +++ b/MacPacker/Features/Checksums/ChecksumWindowController.swift @@ -0,0 +1,230 @@ +// +// ChecksumWindowController.swift +// MacPacker +// + +import AppKit +import Core +import SwiftUI + +@MainActor +final class ChecksumWindowController: NSWindowController, NSWindowDelegate { + private static var retained: [ChecksumWindowController] = [] + + private let model: ChecksumWindowModel + + private init(files: [URL], verifyFromClipboard: Bool) { + model = ChecksumWindowModel( + files: files, + expectedInput: verifyFromClipboard ? NSPasteboard.general.string(forType: .string) ?? "" : "" + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 780, height: 540), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = String(localized: "File Checksums", comment: "Title of the window that calculates and verifies file checksums") + window.isReleasedWhenClosed = false + super.init(window: window) + window.contentView = NSHostingView(rootView: ChecksumWindowView(model: model)) + window.contentMinSize = NSSize(width: 690, height: 360) + window.setContentSize(NSSize(width: 780, height: 540)) + window.delegate = self + window.center() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func show(files: [URL], verifyFromClipboard: Bool) { + guard !files.isEmpty else { return } + let controller = ChecksumWindowController(files: files, verifyFromClipboard: verifyFromClipboard) + retained.append(controller) + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + controller.model.start() + } + + func windowWillClose(_ notification: Notification) { + model.cancel() + Self.retained.removeAll { $0 === self } + } +} + +@MainActor +private final class ChecksumWindowModel: ObservableObject { + struct Row: Identifiable { + let id = UUID() + let file: URL + var checksums: FileChecksums? + var error: String? + } + + @Published var rows: [Row] + @Published var expectedInput: String + private var task: Task? + + init(files: [URL], expectedInput: String) { + rows = files.map { Row(file: $0) } + self.expectedInput = expectedInput + } + + var expected: ExpectedChecksum? { + ChecksumVerifier.expected(from: expectedInput) + } + + func start() { + let files = rows.map(\.file) + task = Task.detached(priority: .userInitiated) { [weak self] in + for (index, file) in files.enumerated() { + if Task.isCancelled { return } + do { + let checksums = try await FileChecksumCalculator.calculate(file) + await self?.finish(index: index, checksums: checksums) + } catch is CancellationError { + return + } catch { + await self?.finish(index: index, error: error.localizedDescription) + } + } + } + } + + func cancel() { + task?.cancel() + task = nil + } + + func paste() { + expectedInput = NSPasteboard.general.string(forType: .string) ?? "" + } + + func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + func saveSHA256(for row: Row) { + guard let checksum = row.checksums?.sha256 else { return } + let panel = NSSavePanel() + panel.nameFieldStringValue = row.file.lastPathComponent + ".sha256" + panel.begin { response in + guard response == .OK, let destination = panel.url else { return } + do { + try "\(checksum) \(row.file.lastPathComponent)\n".write( + to: destination, atomically: true, encoding: .utf8 + ) + } catch { + NSApp.presentError(error) + } + } + } + + private func finish(index: Int, checksums: FileChecksums) { + rows[index].checksums = checksums + } + + private func finish(index: Int, error: String) { + rows[index].error = error + } +} + +private struct ChecksumWindowView: View { + @ObservedObject var model: ChecksumWindowModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("File Checksums", comment: "Heading of the window for file checksums") + .font(.title2.bold()) + Spacer() + Group { + if model.rows.count == 1 { + Text("1 file", comment: "One file being checked") + } else { + Text("\(model.rows.count) files", comment: "Number of files being checked") + } + } + .foregroundStyle(.secondary) + } + + HStack { + TextField( + "Paste a CRC-32, MD5, SHA-1 or SHA-256 checksum", + text: $model.expectedInput + ) + .textFieldStyle(.roundedBorder) + Button(action: model.paste) { + Text("Paste", comment: "Paste an expected checksum from the clipboard") + } + } + if !model.expectedInput.isEmpty && model.expected == nil { + Text("Copy one complete checksum to compare it with the selected files.", + comment: "Shown when clipboard text is not one recognizable checksum") + .foregroundStyle(.orange) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(model.rows) { row in + GroupBox { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(row.file.lastPathComponent) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if let checksums = row.checksums, let expected = model.expected { + Label( + expected.matches(checksums) + ? String(localized: "Checksum matches", comment: "File matches copied checksum") + : String(localized: "Checksum does not match", comment: "File differs from copied checksum"), + systemImage: expected.matches(checksums) ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .foregroundStyle(expected.matches(checksums) ? .green : .red) + } + } + if let checksums = row.checksums { + ForEach(ChecksumAlgorithm.allCases, id: \.self) { algorithm in + HStack(spacing: 8) { + Text(algorithm.rawValue) + .frame(width: 62, alignment: .leading) + Text(checksums.value(for: algorithm)) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + Button { + model.copy(checksums.value(for: algorithm)) + } label: { + Text("Copy", comment: "Copy this file checksum to the clipboard") + } + if algorithm == .sha256 { + Button { + model.saveSHA256(for: row) + } label: { + Text("Save .sha256…", comment: "Write this file's SHA-256 checksum to a checksum file") + } + } + } + } + } else if let error = row.error { + Text(error).foregroundStyle(.red) + } else { + ProgressView() + .controlSize(.small) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } + .padding(20) + .frame(minWidth: 690, minHeight: 360) + } +} diff --git a/MacPacker/Features/Settings/IntegrationSettingsView.swift b/MacPacker/Features/Settings/IntegrationSettingsView.swift index fac44846..f3f40473 100644 --- a/MacPacker/Features/Settings/IntegrationSettingsView.swift +++ b/MacPacker/Features/Settings/IntegrationSettingsView.swift @@ -133,6 +133,10 @@ private struct FinderMenuItemToggle: View { Text(.settingsCompressEachItemSeparately) case .compressFolderContents: Text(.settingsCompressFolderContents) + case .checksums: + Text("Checksums…", comment: "Optional Finder entry for calculating checksums of selected files") + case .verifyChecksum: + Text("Verify Checksum from Clipboard", comment: "Optional Finder entry for checking selected files against a copied checksum") } } } diff --git a/MacPacker/Localizable.xcstrings b/MacPacker/Localizable.xcstrings index 453bc36d..ffa05fbd 100644 --- a/MacPacker/Localizable.xcstrings +++ b/MacPacker/Localizable.xcstrings @@ -1,6 +1,54 @@ { "sourceLanguage": "en", "strings": { + "%lld files": { + "comment": "Number of files being checked", + "extractionState": "manual" + }, + "1 file": { + "comment": "One file being checked", + "extractionState": "manual" + }, + "Checksum does not match": { + "comment": "File differs from copied checksum", + "extractionState": "manual" + }, + "Checksum matches": { + "comment": "File matches copied checksum", + "extractionState": "manual" + }, + "Checksums…": { + "comment": "Finder action and setting for calculating file checksums", + "extractionState": "manual" + }, + "Copy": { + "comment": "Copy this file checksum to the clipboard", + "extractionState": "manual" + }, + "Copy one complete checksum to compare it with the selected files.": { + "comment": "Shown when clipboard text is not one recognizable checksum", + "extractionState": "manual" + }, + "File Checksums": { + "comment": "Window title and heading for calculating and verifying file checksums", + "extractionState": "manual" + }, + "Paste": { + "comment": "Paste an expected checksum from the clipboard", + "extractionState": "manual" + }, + "Paste a CRC-32, MD5, SHA-1 or SHA-256 checksum": { + "comment": "Placeholder for an expected file checksum to compare", + "extractionState": "manual" + }, + "Save .sha256…": { + "comment": "Write this file's SHA-256 checksum to a checksum file", + "extractionState": "manual" + }, + "Verify Checksum from Clipboard": { + "comment": "Finder action and setting for comparing files with a copied checksum", + "extractionState": "manual" + }, "appExtractionInProgress": { "comment": "Title of the alert shown when the user quits while an extraction is running.", "extractionState": "manual", diff --git a/Modules/Sources/Core/Checksums/FileChecksums.swift b/Modules/Sources/Core/Checksums/FileChecksums.swift new file mode 100644 index 00000000..dde375a7 --- /dev/null +++ b/Modules/Sources/Core/Checksums/FileChecksums.swift @@ -0,0 +1,106 @@ +// +// FileChecksums.swift +// Modules +// +// Checksums of files on disk, independent of archive formats and engines. +// + +import CryptoKit +import Foundation +import zlib + +public enum ChecksumAlgorithm: String, CaseIterable, Sendable { + case crc32 = "CRC-32" + case md5 = "MD5" + case sha1 = "SHA-1" + case sha256 = "SHA-256" + + public var hexLength: Int { + switch self { + case .crc32: 8 + case .md5: 32 + case .sha1: 40 + case .sha256: 64 + } + } +} + +public struct FileChecksums: Sendable { + public let crc32: String + public let md5: String + public let sha1: String + public let sha256: String + + public func value(for algorithm: ChecksumAlgorithm) -> String { + switch algorithm { + case .crc32: crc32 + case .md5: md5 + case .sha1: sha1 + case .sha256: sha256 + } + } +} + +public struct ExpectedChecksum: Equatable, Sendable { + public let algorithm: ChecksumAlgorithm + public let hex: String + + public init(algorithm: ChecksumAlgorithm, hex: String) { + self.algorithm = algorithm + self.hex = hex.lowercased() + } + + public func matches(_ checksums: FileChecksums) -> Bool { + checksums.value(for: algorithm) == hex + } +} + +public enum ChecksumVerifier { + /// Accept a bare checksum, or one copied with a label / filename from a + /// download page or sha256sum file. More than one distinct checksum is + /// ambiguous, so it is safer to ask the user to copy just one. + public static func expected(from text: String) -> ExpectedChecksum? { + let pattern = #"(? FileChecksums { + try await Sandbox.access(url: url) { + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + + var crc: uLong = 0 + var md5 = Insecure.MD5() + var sha1 = Insecure.SHA1() + var sha256 = SHA256() + + while let chunk = try file.read(upToCount: 1_048_576), !chunk.isEmpty { + try Task.checkCancellation() + crc = chunk.withUnsafeBytes { bytes in + zlib.crc32(crc, bytes.bindMemory(to: Bytef.self).baseAddress, uInt(chunk.count)) + } + md5.update(data: chunk) + sha1.update(data: chunk) + sha256.update(data: chunk) + } + + return FileChecksums( + crc32: String(format: "%08x", UInt32(crc)), + md5: md5.finalize().map { String(format: "%02x", $0) }.joined(), + sha1: sha1.finalize().map { String(format: "%02x", $0) }.joined(), + sha256: sha256.finalize().map { String(format: "%02x", $0) }.joined() + ) + } + } +} diff --git a/Modules/Sources/FinderMenu/AppUrl.swift b/Modules/Sources/FinderMenu/AppUrl.swift index b61c3a8f..ce938c50 100644 --- a/Modules/Sources/FinderMenu/AppUrl.swift +++ b/Modules/Sources/FinderMenu/AppUrl.swift @@ -17,6 +17,8 @@ public enum AppUrlAction: String, Sendable { case compressEach case compressContents case addToArchive + case checksums + case verifyChecksum } public extension AppUrlAction { @@ -34,7 +36,7 @@ public extension AppUrlAction { case .extractHere, .extractTo: true case .extractToFolder, .open, .compress, .compressEach, - .compressContents, .addToArchive: + .compressContents, .addToArchive, .checksums, .verifyChecksum: false } } diff --git a/Modules/Sources/FinderMenu/FinderMenuItem.swift b/Modules/Sources/FinderMenu/FinderMenuItem.swift index acc2e9ee..57cfcf66 100644 --- a/Modules/Sources/FinderMenu/FinderMenuItem.swift +++ b/Modules/Sources/FinderMenu/FinderMenuItem.swift @@ -42,6 +42,10 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { case compressEachSeparately /// A zip of what is inside a folder, without the folder itself. case compressFolderContents + /// Show the selected files' CRC-32, MD5, SHA-1 and SHA-256 values. + case checksums + /// Compare selected files with a checksum copied to the clipboard. + case verifyChecksum /// A lean default: extracting in place or to a chosen folder, and zip — /// plain and dated. Everything else is opt-in, the way 7-Zip and NanaZip @@ -51,7 +55,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { case .extractHere, .extractToChosenFolder, .compressToZip, .compressToDatedZip: true case .open, .extractToFolder, .addToArchive, .compressTo7z, - .compressEachSeparately, .compressFolderContents: + .compressEachSeparately, .compressFolderContents, + .checksums, .verifyChecksum: false } } @@ -60,8 +65,9 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { /// `folders` folders. public func applies(toFiles files: Int, folders: Int) -> Bool { switch self { - case .open, .extractHere, .extractToFolder, .extractToChosenFolder: - // archives are files; extracting a folder is meaningless + case .open, .extractHere, .extractToFolder, .extractToChosenFolder, + .checksums, .verifyChecksum: + // extracting or hashing a folder is meaningless files > 0 case .compressEachSeparately: // with a single item this is just "Compress to" @@ -73,10 +79,11 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { } } - /// Whether the entry works on archives, which are files. - public var actsOnArchives: Bool { + /// Whether the entry takes only files from a mixed Finder selection. + public var actsOnFilesOnly: Bool { switch self { - case .open, .extractHere, .extractToFolder, .extractToChosenFolder: + case .open, .extractHere, .extractToFolder, .extractToChosenFolder, + .checksums, .verifyChecksum: true case .addToArchive, .compressToZip, .compressToDatedZip, .compressTo7z, .compressEachSeparately, .compressFolderContents: @@ -84,11 +91,10 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { } } - /// The part of a Finder selection this entry sends to the app: archive - /// entries leave out the folders of a mixed selection, the compress entries - /// take all of it. + /// File-only entries leave out the folders of a mixed selection; compress + /// entries take all of it. public func items(from selection: [URL], isDirectory: (URL) -> Bool) -> [URL] { - actsOnArchives ? selection.filter { !isDirectory($0) } : selection + actsOnFilesOnly ? selection.filter { !isDirectory($0) } : selection } /// What this item asks the main app to do. @@ -102,6 +108,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { case .compressToZip, .compressToDatedZip, .compressTo7z: .compress case .compressEachSeparately: .compressEach case .compressFolderContents: .compressContents + case .checksums: .checksums + case .verifyChecksum: .verifyChecksum } } @@ -111,7 +119,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable { switch self { case .compressToZip, .compressToDatedZip, .compressEachSeparately, .compressFolderContents: "zip" case .compressTo7z: "7z" - case .open, .extractHere, .extractToFolder, .extractToChosenFolder, .addToArchive: nil + case .open, .extractHere, .extractToFolder, .extractToChosenFolder, + .addToArchive, .checksums, .verifyChecksum: nil } } diff --git a/Modules/Tests/CoreTests/AppUrlTests.swift b/Modules/Tests/CoreTests/AppUrlTests.swift index c518d01f..28db6fc1 100644 --- a/Modules/Tests/CoreTests/AppUrlTests.swift +++ b/Modules/Tests/CoreTests/AppUrlTests.swift @@ -52,6 +52,18 @@ extension AllCoreTests { #expect(try AppUrl(url: url, scheme: scheme) == request) } + @Test("Both checksum actions survive the Finder-to-app URL handoff") + func checksumRoundTrip() throws { + for action in [AppUrlAction.checksums, .verifyChecksum] { + let request = AppUrl( + action: action, + files: [URL(fileURLWithPath: "/Users/me/a.bin")], + target: URL(fileURLWithPath: "/Users/me") + ) + #expect(try AppUrl(url: #require(request.url(scheme: scheme)), scheme: scheme) == request) + } + } + @Test("The dated archive gets the name the menu showed, even after a minute boundary") func datedNameMatchesTheMenu() throws { // the menu opens a second before the minute turns; the archive is @@ -159,7 +171,8 @@ extension AllCoreTests { #expect(AppUrlAction.extractTo.honorsSmartExtraction) // nothing that does not extract gets a say either - for action in [AppUrlAction.open, .compress, .compressEach, .compressContents, .addToArchive] { + for action in [AppUrlAction.open, .compress, .compressEach, .compressContents, + .addToArchive, .checksums, .verifyChecksum] { #expect(!action.honorsSmartExtraction, "\(action) does not extract") } } diff --git a/Modules/Tests/CoreTests/FileChecksumsTests.swift b/Modules/Tests/CoreTests/FileChecksumsTests.swift new file mode 100644 index 00000000..0c201ee2 --- /dev/null +++ b/Modules/Tests/CoreTests/FileChecksumsTests.swift @@ -0,0 +1,84 @@ +// +// FileChecksumsTests.swift +// Modules +// + +import Foundation +import Testing +@testable import Core + +extension AllCoreTests { + struct FileChecksumsTests { + @Test("A file is read once for CRC-32, MD5, SHA-1 and SHA-256") + func knownValues() async throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: folder) } + let file = folder.appendingPathComponent("abc.txt") + try Data("abc".utf8).write(to: file) + + let checksums = try await FileChecksumCalculator.calculate(file) + #expect(checksums.crc32 == "352441c2") + #expect(checksums.md5 == "900150983cd24fb0d6963f7d28e17f72") + #expect(checksums.sha1 == "a9993e364706816aba3e25717850c26c9cd0d89d") + #expect(checksums.sha256 == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + } + + @Test("An empty file has the standard checksums") + func emptyFile() async throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let checksums = try await FileChecksumCalculator.calculate(file) + #expect(checksums.crc32 == "00000000") + #expect(checksums.md5 == "d41d8cd98f00b204e9800998ecf8427e") + #expect(checksums.sha1 == "da39a3ee5e6b4b0d3255bfef95601890afd80709") + #expect(checksums.sha256 == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + } + + @Test("All hashes remain correct across a read-chunk boundary") + func chunkBoundary() async throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data(repeating: 0x61, count: 1_048_577).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let checksums = try await FileChecksumCalculator.calculate(file) + #expect(checksums.crc32 == "566b6305") + #expect(checksums.md5 == "6f0555ac53cecbf068d354c08863805a") + #expect(checksums.sha1 == "7620f339d6b8cd5fa1bc597f291bf713579b9672") + #expect(checksums.sha256 == "4a3f0c0c213adea174f9a3d4c13177315b588bdb2e9c1012d3d0bf0453ca0f6a") + } + + @Test("Clipboard verification accepts labelled and sha256sum text") + func expectedFromClipboard() async throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data("abc".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let checksums = try await FileChecksumCalculator.calculate(file) + + let sha = try #require(ChecksumVerifier.expected(from: "SHA-256: \(checksums.sha256.uppercased()) abc.txt")) + #expect(sha.algorithm == .sha256) + #expect(sha.matches(checksums)) + let crc = try #require(ChecksumVerifier.expected(from: "CRC-32 = 352441C2")) + #expect(crc.algorithm == .crc32) + #expect(crc.matches(checksums)) + #expect(!ExpectedChecksum(algorithm: .sha256, hex: String(repeating: "0", count: 64)).matches(checksums)) + } + + @Test("A pasted value with no checksum or several checksums is rejected") + func ambiguousClipboard() { + #expect(ChecksumVerifier.expected(from: "No checksum copied") == nil) + #expect(ChecksumVerifier.expected(from: "12345678 abcdef12") == nil) + #expect(ChecksumVerifier.expected(from: String(repeating: "a", count: 65)) == nil) + } + + @Test("A missing file reports an error") + func missingFile() async { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + await #expect(throws: (any Error).self) { + try await FileChecksumCalculator.calculate(file) + } + } + } +} diff --git a/Modules/Tests/CoreTests/FinderMenuSettingsTests.swift b/Modules/Tests/CoreTests/FinderMenuSettingsTests.swift index 51db592f..05cbff00 100644 --- a/Modules/Tests/CoreTests/FinderMenuSettingsTests.swift +++ b/Modules/Tests/CoreTests/FinderMenuSettingsTests.swift @@ -70,6 +70,17 @@ extension AllCoreTests { ]) } + @Test("Checksum actions are opt-in and only appear for files") + func checksumActions() { + #expect(!FinderMenuItem.checksums.isEnabledByDefault) + #expect(!FinderMenuItem.verifyChecksum.isEnabledByDefault) + let files = FinderMenuSettings.visibleItems(files: 1, folders: 0, in: everythingOn()) + #expect(files.suffix(2) == [.checksums, .verifyChecksum]) + let folders = FinderMenuSettings.visibleItems(files: 0, folders: 1, in: everythingOn()) + #expect(!folders.contains(.checksums)) + #expect(!folders.contains(.verifyChecksum)) + } + @Test("Several items get Compress Each, but not a folder's contents") func severalItems() { let visible = FinderMenuSettings.visibleItems(files: 2, folders: 1, in: everythingOn()) @@ -104,7 +115,7 @@ extension AllCoreTests { } } - @Test("Archive entries send only the files of a mixed selection; compress entries send all of it") + @Test("File-only entries omit folders of a mixed selection; compress entries include them") func mixedSelection() { let archive = URL(fileURLWithPath: "/Users/me/a.zip") let folder = URL(fileURLWithPath: "/Users/me/photos") @@ -112,7 +123,8 @@ extension AllCoreTests { let selection = [archive, folder, other] let isDirectory: (URL) -> Bool = { $0 == folder } - for item in [FinderMenuItem.open, .extractHere, .extractToFolder, .extractToChosenFolder] { + for item in [FinderMenuItem.open, .extractHere, .extractToFolder, + .extractToChosenFolder, .checksums, .verifyChecksum] { #expect(item.items(from: selection, isDirectory: isDirectory) == [archive, other]) } for item in [FinderMenuItem.addToArchive, .compressToZip, .compressToDatedZip, .compressTo7z, .compressEachSeparately] { @@ -125,7 +137,8 @@ extension AllCoreTests { #expect(FinderMenuItem.compressToZip.archiveExtension == "zip") #expect(FinderMenuItem.compressToDatedZip.archiveExtension == "zip") #expect(FinderMenuItem.compressTo7z.archiveExtension == "7z") - for item in [FinderMenuItem.open, .extractHere, .extractToFolder, .extractToChosenFolder, .addToArchive] { + for item in [FinderMenuItem.open, .extractHere, .extractToFolder, + .extractToChosenFolder, .addToArchive, .checksums, .verifyChecksum] { #expect(item.archiveExtension == nil) } }