From acafad8cb0cad7cc5434b846c023b187861e4cf3 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 15:36:45 -0500 Subject: [PATCH 1/7] fix(backup): stage and atomically publish complete archives --- Apps/GanchoMac/SettingsView.swift | 7 +- Apps/GanchoiOS/IOSAppModel.swift | 4 +- .../GanchoKit/AtomicArchivePublication.swift | 47 ++++++++ .../Sources/GanchoKit/GanchoArchive.swift | 60 ++++++++-- .../ArchivePublicationTests.swift | 107 ++++++++++++++++++ 5 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift create mode 100644 Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift diff --git a/Apps/GanchoMac/SettingsView.swift b/Apps/GanchoMac/SettingsView.swift index ede10884..8c146d48 100644 --- a/Apps/GanchoMac/SettingsView.swift +++ b/Apps/GanchoMac/SettingsView.swift @@ -356,7 +356,8 @@ private struct GeneralSettingsTab: View { Task { do { try await GanchoArchive.export( - from: store, to: url, options: .init(excludeSensitive: true)) + from: store, to: url, options: .init(excludeSensitive: true), + replacement: .replaceExisting) transferNote = String(localized: "Backup saved.") } catch { // Same content-free discipline as restore: no paths, no @@ -365,8 +366,8 @@ private struct GeneralSettingsTab: View { model.diagnostics.record( String(localized: "Backup"), String(localized: "A backup couldn’t be created.")) - // Best effort: don't leave a half-written archive behind. - try? FileManager.default.removeItem(at: url) + // The exporter owns and cleans its private stage. The selected + // destination may be the user's last usable backup. } } } diff --git a/Apps/GanchoiOS/IOSAppModel.swift b/Apps/GanchoiOS/IOSAppModel.swift index 032c7a9f..cdcce77e 100644 --- a/Apps/GanchoiOS/IOSAppModel.swift +++ b/Apps/GanchoiOS/IOSAppModel.swift @@ -947,13 +947,13 @@ final class IOSAppModel { guard let grdb = grdbForEngines else { return nil } let dir = FileManager.default.temporaryDirectory .appendingPathComponent("gancho-backup.ganchoarchive", isDirectory: true) - try? FileManager.default.removeItem(at: dir) // Detector-flagged secrets never leave the encrypted store via backup: // they carry a short expiry precisely so they don't persist, and an // archive in Files is permanent plaintext. guard (try? await GanchoArchive.export( - from: grdb, to: dir, options: .init(excludeSensitive: true))) != nil + from: grdb, to: dir, options: .init(excludeSensitive: true), + replacement: .replaceExisting)) != nil else { return nil } return dir } diff --git a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift new file mode 100644 index 00000000..5590f152 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift @@ -0,0 +1,47 @@ +import Darwin +import Foundation + +/// A complete archive is published with one same-filesystem namespace change. +/// Existing archives are exchanged atomically, never removed before promotion. +/// The old archive lands in the owned stage and can be recovered after a crash. +enum AtomicArchivePublication { + static func makeStage(beside destination: URL) throws -> URL { + let parent = destination.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + let stage = parent.appendingPathComponent(".ganchoarchive-stage-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: stage, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + return stage + } + + static func publish( + _ stage: URL, as destination: URL, replacement: GanchoArchive.ReplacementPolicy + ) throws { + let installed = rename(stage, to: destination, flags: UInt32(RENAME_EXCL)) + if installed == 0 { return } + let failure = errno + guard failure == EEXIST, replacement == .replaceExisting else { + throw POSIXError(POSIXErrorCode(rawValue: failure) ?? .EIO) + } + let attributes = try FileManager.default.attributesOfItem(atPath: destination.path) + guard attributes[.type] as? FileAttributeType == .typeDirectory else { + throw CocoaError(.fileWriteFileExists) + } + guard rename(stage, to: destination, flags: UInt32(RENAME_SWAP)) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + // Cleanup belongs to the caller's stage defer. It is best-effort after + // successful promotion: cleanup failure must not report export failure + // after the destination has already changed. + } + + private static func rename(_ source: URL, to destination: URL, flags: UInt32) -> Int32 { + source.withUnsafeFileSystemRepresentation { sourcePath in + destination.withUnsafeFileSystemRepresentation { destinationPath in + guard let sourcePath, let destinationPath else { errno = EINVAL; return -1 } + return renameatx_np(AT_FDCWD, sourcePath, AT_FDCWD, destinationPath, flags) + } + } + } +} diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift index 6f2419e7..f4681718 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift @@ -9,6 +9,15 @@ import GRDB public enum GanchoArchive { public static let currentVersion = 1 + public enum ReplacementPolicy: Sendable, Equatable { + case failIfExists + /// Callers must obtain the user's replacement decision before selecting + /// this policy for an existing user-owned archive. + case replaceExisting + } + + enum ExportCheckpoint: Sendable, Equatable { case rows, blobs, manifest, promotion } + public struct Options: Sendable, Equatable { /// Drop sensitive clips entirely from the archive. public var excludeSensitive: Bool @@ -44,10 +53,35 @@ public enum GanchoArchive { @discardableResult public static func export( - from store: GRDBClipboardStore, to directory: URL, options: Options = Options() + from store: GRDBClipboardStore, to directory: URL, options: Options = Options(), + replacement: ReplacementPolicy = .failIfExists + ) async throws -> Manifest { + try await export( + from: store, to: directory, options: options, replacement: replacement, + checkpoint: { _ in }) + } + + static func export( + from store: GRDBClipboardStore, to directory: URL, options: Options, + replacement: ReplacementPolicy, + checkpoint: @Sendable (ExportCheckpoint) throws -> Void ) async throws -> Manifest { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let stage = try AtomicArchivePublication.makeStage(beside: directory) + defer { try? FileManager.default.removeItem(at: stage) } + try Task.checkCancellation() + let manifest = try await exportIntoStage( + from: store, to: stage, options: options, checkpoint: checkpoint) + try validateExport(manifest, in: stage) + try checkpoint(.promotion) + try Task.checkCancellation() + try AtomicArchivePublication.publish(stage, as: directory, replacement: replacement) + return manifest + } + private static func exportIntoStage( + from store: GRDBClipboardStore, to directory: URL, options: Options, + checkpoint: @Sendable (ExportCheckpoint) throws -> Void + ) async throws -> Manifest { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.sortedKeys] @@ -79,12 +113,11 @@ public enum GanchoArchive { try? FileManager.default.removeItem(at: staged) throw error } - // Published only once it is whole, so a failed export leaves no - // half-written clips.json where the old code wrote atomically — and, - // just as importantly, leaves the PREVIOUS one intact. Remove-then-move - // would not: it exposes a window with no clips.json at all, and a - // failure after the remove destroys a good archive. + // This rename is private to the owned stage. Nothing in the selected + // destination changes until all files and their manifest are complete. try AtomicFileReplace.publish(staged: staged, as: clipsURL) + try checkpoint(.rows) + try Task.checkCancellation() let clipCount = streamed.count let referencedBlobs = streamed.blobs @@ -95,6 +128,7 @@ public enum GanchoArchive { try FileManager.default.createDirectory( at: blobDir, withIntermediateDirectories: true) for hash in referencedBlobs { + try Task.checkCancellation() guard let data = try store.blobsForMaintenance.read(hash: hash) else { throw ArchiveError.corruptArchive( "source store is missing or has a corrupt referenced blob") @@ -110,6 +144,8 @@ public enum GanchoArchive { } } + try checkpoint(.blobs) + try Task.checkCancellation() let manifest = Manifest( version: currentVersion, exportedAt: .now, clipCount: clipCount, checksums: checksums) @@ -118,9 +154,18 @@ public enum GanchoArchive { manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] try manifestEncoder.encode(manifest) .write(to: directory.appendingPathComponent("manifest.json"), options: .atomic) + try checkpoint(.manifest) return manifest } + private static func validateExport(_ manifest: Manifest, in directory: URL) throws { + for (path, expected) in manifest.checksums { + try Task.checkCancellation() + guard sha256(try Data(contentsOf: directory.appendingPathComponent(path))) == expected + else { throw ArchiveError.checksumMismatch(path) } + } + } + // MARK: - Restore (merge with dedupe; transactional rollback) @discardableResult @@ -164,6 +209,7 @@ public enum GanchoArchive { try emit(Data("[".utf8)) let cursor = try ClipRow.order(Column("createdAt").asc).fetchCursor(db) while var row = try cursor.next() { + try Task.checkCancellation() if options.excludeSensitive, row.requiresProtectedExport { continue } if options.metadataOnly { row.contentText = nil diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift new file mode 100644 index 00000000..071ae8d1 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import GRDB +import Testing + +@_spi(GanchoInternal) @testable import GanchoKit + +@Suite("Whole-archive publication preserves the previous backup") +struct ArchivePublicationTests { + private struct InjectedFailure: Error {} + + private func makeStore(in root: URL, name: String) throws -> GRDBClipboardStore { + let store = GRDBClipboardStore( + writer: try DatabaseQueue(), + blobs: BlobStore(directory: root.appendingPathComponent(name))) + try store.migrate() + return store + } + + private func snapshot(_ root: URL) throws -> [String: Data] { + var result: [String: Data] = [:] + let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) + while let file = enumerator?.nextObject() as? URL { + if (try file.resourceValues(forKeys: [.isRegularFileKey])).isRegularFile == true { + result[String(file.path.dropFirst(root.path.count))] = try Data(contentsOf: file) + } + } + return result + } + + @Test("Source and checkpoint failures preserve every old byte and restoreability") + func preservesExistingArchive() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let original = try makeStore(in: root, name: "original-blobs") + try await original.insert( + ClipItem(preview: "original", contentHash: "original"), content: .text("original")) + let destination = root.appendingPathComponent("backup.ganchoarchive") + try await GanchoArchive.export(from: original, to: destination) + try Data("unrelated sentinel".utf8).write(to: destination.appendingPathComponent("sentinel")) + let before = try snapshot(destination) + let replacement = try makeStore(in: root, name: "replacement-blobs") + try await replacement.insert( + ClipItem(preview: "replacement", contentHash: "replacement"), content: .text("new")) + + for failure in [GanchoArchive.ExportCheckpoint.rows, .blobs, .manifest, .promotion] { + await #expect(throws: InjectedFailure.self) { + try await GanchoArchive.export( + from: replacement, to: destination, options: .init(), + replacement: .replaceExisting, + checkpoint: { checkpoint in + if checkpoint == failure { throw InjectedFailure() } + }) + } + #expect(try snapshot(destination) == before) + let restored = try makeStore(in: root, name: "restored-\(UUID().uuidString)") + #expect(try await GanchoArchive.restore(from: destination, into: restored).inserted == 1) + let siblings = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) + } + } + + @Test("Cancellation and missing source blobs cannot damage an existing backup") + func cancellationAndMissingBlob() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "source-blobs") + let destination = root.appendingPathComponent("backup.ganchoarchive") + try await GanchoArchive.export(from: source, to: destination) + let before = try snapshot(destination) + let canceled = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + } + await #expect(throws: CancellationError.self) { try await canceled.value } + let payload = Data("binary fixture".utf8) + try await source.insert( + ClipItem(kind: .image, preview: "binary", contentHash: "binary"), + content: .binary(data: payload, typeIdentifier: "public.data")) + source.blobsForMaintenance.delete(hash: GanchoArchive.sha256(payload)) + await #expect(throws: (any Error).self) { + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + } + #expect(try snapshot(destination) == before) + } + + @Test("Replacement is explicit and successful promotion is self-consistent") + func explicitSuccessfulReplacement() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "blobs") + let destination = root.appendingPathComponent("backup.ganchoarchive") + try await GanchoArchive.export(from: source, to: destination) + let before = try snapshot(destination) + try await source.insert( + ClipItem(preview: "new", contentHash: "new"), content: .text("new")) + await #expect(throws: (any Error).self) { + try await GanchoArchive.export(from: source, to: destination) + } + #expect(try snapshot(destination) == before) + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + let target = try makeStore(in: root, name: "restore-blobs") + #expect(try await GanchoArchive.restore(from: destination, into: target).inserted == 1) + } +} From 6f75ec250cd76bc39ee0c925a926d44c33b93e9c Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 15:54:18 -0500 Subject: [PATCH 2/7] fix(backup): validate staged archive with bounded memory --- .../Sources/GanchoKit/GanchoArchive.swift | 22 +++++++++++++++++-- .../ArchivePublicationTests.swift | 6 +++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift index f4681718..82231181 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift @@ -159,11 +159,29 @@ public enum GanchoArchive { } private static func validateExport(_ manifest: Manifest, in directory: URL) throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let stored = try decoder.decode( + Manifest.self, from: Data(contentsOf: directory.appendingPathComponent("manifest.json"))) + guard stored.version == manifest.version, stored.clipCount == manifest.clipCount, + stored.checksums == manifest.checksums + else { throw ArchiveError.corruptArchive("staged manifest does not match the export") } for (path, expected) in manifest.checksums { + guard try fileDigest(directory.appendingPathComponent(path)) == expected else { + throw ArchiveError.checksumMismatch(path) + } + } + } + + private static func fileDigest(_ file: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + var hasher = SHA256() + while let bytes = try handle.read(upToCount: 64 << 10), !bytes.isEmpty { try Task.checkCancellation() - guard sha256(try Data(contentsOf: directory.appendingPathComponent(path))) == expected - else { throw ArchiveError.checksumMismatch(path) } + hasher.update(data: bytes) } + return hex(hasher.finalize()) } // MARK: - Restore (merge with dedupe; transactional rollback) diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift index 071ae8d1..38943bd5 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -36,7 +36,8 @@ struct ArchivePublicationTests { ClipItem(preview: "original", contentHash: "original"), content: .text("original")) let destination = root.appendingPathComponent("backup.ganchoarchive") try await GanchoArchive.export(from: original, to: destination) - try Data("unrelated sentinel".utf8).write(to: destination.appendingPathComponent("sentinel")) + try Data("unrelated sentinel".utf8) + .write(to: destination.appendingPathComponent("sentinel")) let before = try snapshot(destination) let replacement = try makeStore(in: root, name: "replacement-blobs") try await replacement.insert( @@ -53,7 +54,8 @@ struct ArchivePublicationTests { } #expect(try snapshot(destination) == before) let restored = try makeStore(in: root, name: "restored-\(UUID().uuidString)") - #expect(try await GanchoArchive.restore(from: destination, into: restored).inserted == 1) + let summary = try await GanchoArchive.restore(from: destination, into: restored) + #expect(summary.inserted == 1) let siblings = try FileManager.default.contentsOfDirectory(atPath: root.path) #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) } From 130840df440e0841aa82cb974c1cddfcdf4b7169 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 16:03:15 -0500 Subject: [PATCH 3/7] test(backup): cover rename failures and document replacement policy --- .../ArchivePublicationTests.swift | 29 +++++++++ docs/ARCHIVE_PUBLICATION.md | 62 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 docs/ARCHIVE_PUBLICATION.md diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift index 38943bd5..ce0a224e 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -106,4 +106,33 @@ struct ArchivePublicationTests { let target = try makeStore(in: root, name: "restore-blobs") #expect(try await GanchoArchive.restore(from: destination, into: target).inserted == 1) } + + @Test("An actual rename failure preserves the prior archive and recovery stage") + func actualRenameFailurePreservesBackup() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "source-blobs") + let destination = root.appendingPathComponent("backup.ganchoarchive") + try await GanchoArchive.export(from: source, to: destination) + try Data("sentinel".utf8).write(to: destination.appendingPathComponent("sentinel")) + let before = try snapshot(destination) + let stage = try AtomicArchivePublication.makeStage(beside: destination) + try Data("replacement fixture".utf8).write(to: stage.appendingPathComponent("clips.json")) + let recoverable = root.appendingPathComponent(".recoverable-fixture-stage") + try FileManager.default.moveItem(at: stage, to: recoverable) + // Exercise the real Darwin syscall's ENOENT path, rather than throwing + // at the checkpoint before calling the publication primitive. + #expect(throws: POSIXError.self) { + try AtomicArchivePublication.publish( + stage, as: destination, replacement: .replaceExisting) + } + #expect(try snapshot(destination) == before) + let stagedBytes = try Data(contentsOf: recoverable.appendingPathComponent("clips.json")) + #expect(stagedBytes == Data("replacement fixture".utf8)) + let target = try makeStore(in: root, name: "restore-blobs") + let summary = try await GanchoArchive.restore(from: destination, into: target) + #expect(summary.inserted == 0) + } + } diff --git a/docs/ARCHIVE_PUBLICATION.md b/docs/ARCHIVE_PUBLICATION.md new file mode 100644 index 00000000..b40985c7 --- /dev/null +++ b/docs/ARCHIVE_PUBLICATION.md @@ -0,0 +1,62 @@ +# Portable archive publication + +`GanchoArchive.export` creates a version-1 `.ganchoarchive` directory containing +`clips.json`, declared binary blobs, and a checksummed manifest. The row encoding +and restore format are unchanged. + +## Destination replacement policy + +The API now defaults to `replacement: .failIfExists`. Existing source calls still +compile, and exports to fresh destinations behave as before. A call targeting an +existing archive now fails without changing it unless the caller explicitly +selects `.replaceExisting`. This is an intentional safety change from the former +implicit, per-file overwrite behavior. + +Select `.replaceExisting` only after obtaining a replacement decision for a +user-owned destination, or for a temporary destination owned by the application. +It replaces the complete directory, including files not declared in its manifest; +it is not a merge into an arbitrary directory. + +Current production call sites: + +- macOS: `Apps/GanchoMac/SettingsView.swift`, `backupHistory()`, passes + `.replaceExisting` after `NSSavePanel` accepts the destination and its normal + replacement decision +- iOS: `Apps/GanchoiOS/IOSAppModel.swift`, `makeBackupArchive()`, passes + `.replaceExisting` for its fixed application-owned temporary archive, before + the system file exporter presents the user's final Files destination +- CLI: `Packages/GanchoKit/Sources/gancho/GanchoCLI.swift`, `runExport()`, produces + plain JSON or CSV through `exportJSON`/`exportCSV`. It does not call + `GanchoArchive.export` or produce a portable archive, so this directory + replacement policy does not change its output behavior + +## Ownership and failure behavior + +The exporter builds the complete result in a private, uniquely named sibling +stage. It verifies the stored manifest and streams file checksum validation with +bounded memory. Cancellation is checked while writing and validating, and before +publication. None of those operations modifies the selected destination. + +Publication uses Darwin's exclusive rename for a fresh destination and atomic +`RENAME_SWAP` for explicitly replacing an existing directory on a filesystem that +supports it. Unsupported filesystem operations fail while retaining the previous +archive; there is no remove-then-move fallback. A successful exchange leaves the +former archive at the owned stage path until best-effort cleanup completes. An +interruption can therefore leave a recoverable hidden stage. + +Failure cleanup removes only the stage owned by that export. Neither application +call site deletes the selected destination on failure. Cleanup after a successful +exchange is best-effort, so an old-stage cleanup failure does not falsely report +that an already published backup failed. + +## Qualification + +The regression suite checks byte-for-byte preservation of an existing archive and +an unrelated sentinel, restoreability, cancellation, missing source blobs, +injected row/blob/manifest/promotion failures, explicit replacement refusal, +successful complete replacement, and the real Darwin rename error path using +isolated fixture directories. No live backup or Keychain is used. + +Native macOS/iOS compilation, test execution, Save Panel behavior, and target +filesystem support must still be qualified by CI or disposable native fixtures. +Source-directed POSIX models are supplementary evidence, not those native gates. From 2b065bb9b660c36f1e928db589259269bb275abb Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 16:32:52 -0500 Subject: [PATCH 4/7] style(backup): keep manifest decoding within formatter width --- Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift index 82231181..95467514 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift @@ -161,8 +161,8 @@ public enum GanchoArchive { private static func validateExport(_ manifest: Manifest, in directory: URL) throws { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let stored = try decoder.decode( - Manifest.self, from: Data(contentsOf: directory.appendingPathComponent("manifest.json"))) + let manifestURL = directory.appendingPathComponent("manifest.json") + let stored = try decoder.decode(Manifest.self, from: Data(contentsOf: manifestURL)) guard stored.version == manifest.version, stored.clipCount == manifest.clipCount, stored.checksums == manifest.checksums else { throw ArchiveError.corruptArchive("staged manifest does not match the export") } From 9fceab898be6ae6eec1bdc242728e0be02ba963c Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 18:14:19 -0500 Subject: [PATCH 5/7] style(backup): expand Darwin rename failure guard --- .../Sources/GanchoKit/AtomicArchivePublication.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift index 5590f152..ebb77615 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift @@ -39,7 +39,10 @@ enum AtomicArchivePublication { private static func rename(_ source: URL, to destination: URL, flags: UInt32) -> Int32 { source.withUnsafeFileSystemRepresentation { sourcePath in destination.withUnsafeFileSystemRepresentation { destinationPath in - guard let sourcePath, let destinationPath else { errno = EINVAL; return -1 } + guard let sourcePath, let destinationPath else { + errno = EINVAL + return -1 + } return renameatx_np(AT_FDCWD, sourcePath, AT_FDCWD, destinationPath, flags) } } From d5f8e70d3b1e9514bad9b162768f3e5a57eccfbd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:42:54 +0000 Subject: [PATCH 6/7] fix(backup): guard replacement and publish new archives on plain volumes Replacement swaps the whole destination and then deletes the old one, so a save panel pointed at an ordinary folder would erase it. Only replace a directory that is already a Gancho archive (it has a manifest) or is empty apart from .DS_Store; refuse anything else untouched. exFAT, FAT and some SMB volumes reject renameatx_np flags with ENOTSUP or EINVAL, which made every backup there fail. A new destination now falls back to a plain rename after a non-existence check; replacing an archive on such a volume still fails and keeps the previous backup. The iOS temporary archive path is app-owned, so a manifest-less leftover from an interrupted in-place export by an older build is cleared first. Drop the cancellation check inside GRDB's read closure: it runs on GRDB's queue with no current task, and GRDB already cancels the read itself. --- Apps/GanchoiOS/IOSAppModel.swift | 7 ++ .../GanchoKit/AtomicArchivePublication.swift | 68 +++++++++++++----- .../Sources/GanchoKit/GanchoArchive.swift | 1 - .../ArchivePublicationTests.swift | 69 +++++++++++++++++++ docs/ARCHIVE_PUBLICATION.md | 21 ++++-- 5 files changed, 142 insertions(+), 24 deletions(-) diff --git a/Apps/GanchoiOS/IOSAppModel.swift b/Apps/GanchoiOS/IOSAppModel.swift index cdcce77e..0126fa26 100644 --- a/Apps/GanchoiOS/IOSAppModel.swift +++ b/Apps/GanchoiOS/IOSAppModel.swift @@ -947,6 +947,13 @@ final class IOSAppModel { guard let grdb = grdbForEngines else { return nil } let dir = FileManager.default.temporaryDirectory .appendingPathComponent("gancho-backup.ganchoarchive", isDirectory: true) + // This temporary path is app-owned. An older build exported in place, + // so an interrupted export may have left a folder without a manifest, + // which replacement refuses; clear only that leftover. + let manifest = dir.appendingPathComponent("manifest.json") + if !FileManager.default.fileExists(atPath: manifest.path) { + try? FileManager.default.removeItem(at: dir) + } // Detector-flagged secrets never leave the encrypted store via backup: // they carry a short expiry precisely so they don't persist, and an // archive in Files is permanent plaintext. diff --git a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift index ebb77615..a53c629e 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift @@ -5,6 +5,9 @@ import Foundation /// Existing archives are exchanged atomically, never removed before promotion. /// The old archive lands in the owned stage and can be recovered after a crash. enum AtomicArchivePublication { + /// One `renameatx_np` call: 0 on success, otherwise the `errno` it failed with. + typealias Renamer = @Sendable (_ source: URL, _ destination: URL, _ flags: UInt32) -> Int32 + static func makeStage(beside destination: URL) throws -> URL { let parent = destination.deletingLastPathComponent() try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) @@ -16,35 +19,66 @@ enum AtomicArchivePublication { } static func publish( - _ stage: URL, as destination: URL, replacement: GanchoArchive.ReplacementPolicy + _ stage: URL, as destination: URL, replacement: GanchoArchive.ReplacementPolicy, + renamer: Renamer = AtomicArchivePublication.systemRename ) throws { - let installed = rename(stage, to: destination, flags: UInt32(RENAME_EXCL)) - if installed == 0 { return } - let failure = errno - guard failure == EEXIST, replacement == .replaceExisting else { - throw POSIXError(POSIXErrorCode(rawValue: failure) ?? .EIO) + let exclusive = renamer(stage, destination, UInt32(RENAME_EXCL)) + if exclusive == 0 { return } + if exclusive == ENOTSUP || exclusive == EINVAL { + // exFAT, FAT and some SMB volumes cannot rename exclusively. Only a + // NEW destination may fall back; there is no previous archive to + // protect there, and replacement still requires an atomic exchange. + guard !itemExists(at: destination) else { + throw CocoaError(.fileWriteFileExists) + } + let plain = renamer(stage, destination, 0) + guard plain == 0 else { throw posixError(plain) } + return } + guard exclusive == EEXIST, replacement == .replaceExisting else { + throw posixError(exclusive) + } + try requireReplaceableArchive(at: destination) + let swapped = renamer(stage, destination, UInt32(RENAME_SWAP)) + guard swapped == 0 else { throw posixError(swapped) } + // Cleanup belongs to the caller's stage defer. It is best-effort after + // successful promotion: cleanup failure must not report export failure + // after the destination has already changed. + } + + /// Replacement exchanges the whole directory and the caller then deletes + /// the old one, so only a previous Gancho archive (it has a manifest) or + /// an empty folder may be replaced. Any other folder a save panel lets the + /// user name is refused untouched. + static func requireReplaceableArchive(at destination: URL) throws { let attributes = try FileManager.default.attributesOfItem(atPath: destination.path) guard attributes[.type] as? FileAttributeType == .typeDirectory else { throw CocoaError(.fileWriteFileExists) } - guard rename(stage, to: destination, flags: UInt32(RENAME_SWAP)) == 0 else { - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + let entries = try FileManager.default.contentsOfDirectory(atPath: destination.path) + .filter { $0 != ".DS_Store" } + guard entries.isEmpty || entries.contains("manifest.json") else { + throw CocoaError(.fileWriteFileExists) } - // Cleanup belongs to the caller's stage defer. It is best-effort after - // successful promotion: cleanup failure must not report export failure - // after the destination has already changed. } - private static func rename(_ source: URL, to destination: URL, flags: UInt32) -> Int32 { + static let systemRename: Renamer = { source, destination, flags in source.withUnsafeFileSystemRepresentation { sourcePath in destination.withUnsafeFileSystemRepresentation { destinationPath in - guard let sourcePath, let destinationPath else { - errno = EINVAL - return -1 - } - return renameatx_np(AT_FDCWD, sourcePath, AT_FDCWD, destinationPath, flags) + guard let sourcePath, let destinationPath else { return EINVAL } + let result = renameatx_np( + AT_FDCWD, sourcePath, AT_FDCWD, destinationPath, flags) + return result == 0 ? 0 : errno } } } + + /// `lstat`-based: a dangling symlink still counts as an existing item. + private static func itemExists(at url: URL) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: url.path)) != nil + } + + private static func posixError(_ code: Int32) -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } } diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift index 95467514..d1116774 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift @@ -227,7 +227,6 @@ public enum GanchoArchive { try emit(Data("[".utf8)) let cursor = try ClipRow.order(Column("createdAt").asc).fetchCursor(db) while var row = try cursor.next() { - try Task.checkCancellation() if options.excludeSensitive, row.requiresProtectedExport { continue } if options.metadataOnly { row.contentText = nil diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift index ce0a224e..09907120 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -135,4 +135,73 @@ struct ArchivePublicationTests { #expect(summary.inserted == 0) } + @Test("Replacement refuses a folder that is not a Gancho archive") + func replacementRefusesForeignFolder() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "blobs") + let destination = root.appendingPathComponent("Projects") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try Data("user notes".utf8).write(to: destination.appendingPathComponent("notes.txt")) + let before = try snapshot(destination) + + await #expect(throws: CocoaError.self) { + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + } + #expect(try snapshot(destination) == before) + let siblings = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) + } + + @Test("Replacement accepts an empty folder, ignoring Finder metadata") + func replacementAcceptsEmptyFolder() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "blobs") + try await source.insert( + ClipItem(preview: "kept", contentHash: "kept"), content: .text("kept")) + let destination = root.appendingPathComponent("backup.ganchoarchive") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try Data().write(to: destination.appendingPathComponent(".DS_Store")) + + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + let target = try makeStore(in: root, name: "restore-blobs") + #expect(try await GanchoArchive.restore(from: destination, into: target).inserted == 1) + } + + @Test( + "Volumes without exclusive rename publish new archives and never replace", + arguments: [ENOTSUP, EINVAL]) + func unsupportedExclusiveRename(failure: Int32) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + // Models exFAT/FAT/SMB: any flagged rename fails, a plain one works. + let renamer: AtomicArchivePublication.Renamer = { source, destination, flags in + flags == 0 ? AtomicArchivePublication.systemRename(source, destination, 0) : failure + } + + let fresh = root.appendingPathComponent("fresh.ganchoarchive") + let stage = try AtomicArchivePublication.makeStage(beside: fresh) + try Data("fresh".utf8).write(to: stage.appendingPathComponent("manifest.json")) + try AtomicArchivePublication.publish( + stage, as: fresh, replacement: .failIfExists, renamer: renamer) + #expect( + try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) + == Data("fresh".utf8)) + + for policy in [GanchoArchive.ReplacementPolicy.failIfExists, .replaceExisting] { + let second = try AtomicArchivePublication.makeStage(beside: fresh) + defer { try? FileManager.default.removeItem(at: second) } + try Data("second".utf8).write(to: second.appendingPathComponent("manifest.json")) + #expect(throws: CocoaError.self) { + try AtomicArchivePublication.publish( + second, as: fresh, replacement: policy, renamer: renamer) + } + #expect( + try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) + == Data("fresh".utf8)) + } + } } diff --git a/docs/ARCHIVE_PUBLICATION.md b/docs/ARCHIVE_PUBLICATION.md index b40985c7..44a8b471 100644 --- a/docs/ARCHIVE_PUBLICATION.md +++ b/docs/ARCHIVE_PUBLICATION.md @@ -15,7 +15,11 @@ implicit, per-file overwrite behavior. Select `.replaceExisting` only after obtaining a replacement decision for a user-owned destination, or for a temporary destination owned by the application. It replaces the complete directory, including files not declared in its manifest; -it is not a merge into an arbitrary directory. +it is not a merge into an arbitrary directory. Replacement is therefore limited to +a directory that is already a Gancho archive (it contains `manifest.json`) or is +empty apart from Finder's `.DS_Store`. Any other existing folder — for example one +named in a save panel by mistake — is refused untouched with a +"file exists" error. Current production call sites: @@ -24,7 +28,9 @@ Current production call sites: replacement decision - iOS: `Apps/GanchoiOS/IOSAppModel.swift`, `makeBackupArchive()`, passes `.replaceExisting` for its fixed application-owned temporary archive, before - the system file exporter presents the user's final Files destination + the system file exporter presents the user's final Files destination. A + leftover at that temporary path without a manifest (an interrupted in-place + export from an older build) is removed first, since replacement would refuse it - CLI: `Packages/GanchoKit/Sources/gancho/GanchoCLI.swift`, `runExport()`, produces plain JSON or CSV through `exportJSON`/`exportCSV`. It does not call `GanchoArchive.export` or produce a portable archive, so this directory @@ -34,13 +40,16 @@ Current production call sites: The exporter builds the complete result in a private, uniquely named sibling stage. It verifies the stored manifest and streams file checksum validation with -bounded memory. Cancellation is checked while writing and validating, and before -publication. None of those operations modifies the selected destination. +bounded memory. Cancellation is checked between export phases, per blob, while validating, and +before publication; the row stream inside GRDB's read is cancelled by GRDB itself. None of those operations modifies the selected destination. Publication uses Darwin's exclusive rename for a fresh destination and atomic `RENAME_SWAP` for explicitly replacing an existing directory on a filesystem that -supports it. Unsupported filesystem operations fail while retaining the previous -archive; there is no remove-then-move fallback. A successful exchange leaves the +supports it. Volumes without `renameatx_np` flag support (exFAT, FAT and some SMB +shares report `ENOTSUP` or `EINVAL`) can still receive a NEW archive: after +checking that nothing exists at the destination, the stage is moved with a plain +rename. Replacing an existing archive on such a volume fails while retaining the +previous archive; there is no remove-then-move fallback. A successful exchange leaves the former archive at the owned stage path until best-effort cleanup completes. An interruption can therefore leave a recoverable hidden stage. From aebe5678e7fcd037bc314fdb3317a43123adafec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:08:31 +0000 Subject: [PATCH 7/7] fix(backup): replace only real archives and refuse early - Require the existing manifest.json to decode as a Gancho manifest before a replacement swaps the folder, so a project folder that happens to hold its own manifest.json is never moved into the stage and deleted - Check that the destination is publishable before streaming the export into a stage that would only be discarded - Clear the app-owned iOS temporary archive before every export again, so a failed export never leaves the previous plaintext archive behind - Write files in place inside the private stage, which is discarded whole on failure - Cover a foreign manifest, stage cleanup after replacement and after a failed export, and keep the unsupported-volume test on the swap path --- Apps/GanchoiOS/IOSAppModel.swift | 11 ++---- .../GanchoKit/AtomicArchivePublication.swift | 20 ++++++++-- .../GanchoKit/GanchoArchive+Restore.swift | 10 +++++ .../Sources/GanchoKit/GanchoArchive.swift | 25 +++++-------- .../ArchivePublicationTests.swift | 37 ++++++++++++++++--- .../ArchiveStreamingTests.swift | 14 ++++--- docs/ARCHIVE_PUBLICATION.md | 22 ++++++----- 7 files changed, 92 insertions(+), 47 deletions(-) diff --git a/Apps/GanchoiOS/IOSAppModel.swift b/Apps/GanchoiOS/IOSAppModel.swift index 0126fa26..bdb310c9 100644 --- a/Apps/GanchoiOS/IOSAppModel.swift +++ b/Apps/GanchoiOS/IOSAppModel.swift @@ -947,13 +947,10 @@ final class IOSAppModel { guard let grdb = grdbForEngines else { return nil } let dir = FileManager.default.temporaryDirectory .appendingPathComponent("gancho-backup.ganchoarchive", isDirectory: true) - // This temporary path is app-owned. An older build exported in place, - // so an interrupted export may have left a folder without a manifest, - // which replacement refuses; clear only that leftover. - let manifest = dir.appendingPathComponent("manifest.json") - if !FileManager.default.fileExists(atPath: manifest.path) { - try? FileManager.default.removeItem(at: dir) - } + // This temporary path is app-owned: clear whatever an earlier export + // (or an interrupted in-place export from an older build) left there, + // so a failed export never leaves a stale plaintext archive behind. + try? FileManager.default.removeItem(at: dir) // Detector-flagged secrets never leave the encrypted store via backup: // they carry a short expiry precisely so they don't persist, and an // archive in Files is permanent plaintext. diff --git a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift index a53c629e..6462ece5 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift @@ -46,10 +46,22 @@ enum AtomicArchivePublication { // after the destination has already changed. } + /// Refuses, before any export work, a destination that `publish` could + /// never accept. `publish` repeats the checks at promotion time, so this + /// only spares streaming the whole history into a stage that is discarded. + static func requirePublishable( + _ destination: URL, replacement: GanchoArchive.ReplacementPolicy + ) throws { + guard itemExists(at: destination) else { return } + guard replacement == .replaceExisting else { throw CocoaError(.fileWriteFileExists) } + try requireReplaceableArchive(at: destination) + } + /// Replacement exchanges the whole directory and the caller then deletes - /// the old one, so only a previous Gancho archive (it has a manifest) or - /// an empty folder may be replaced. Any other folder a save panel lets the - /// user name is refused untouched. + /// the old one, so only a previous Gancho archive (its `manifest.json` + /// decodes as a Gancho manifest) or an empty folder may be replaced. Any + /// other folder a save panel lets the user name, including one that merely + /// holds an unrelated `manifest.json`, is refused untouched. static func requireReplaceableArchive(at destination: URL) throws { let attributes = try FileManager.default.attributesOfItem(atPath: destination.path) guard attributes[.type] as? FileAttributeType == .typeDirectory else { @@ -57,7 +69,7 @@ enum AtomicArchivePublication { } let entries = try FileManager.default.contentsOfDirectory(atPath: destination.path) .filter { $0 != ".DS_Store" } - guard entries.isEmpty || entries.contains("manifest.json") else { + guard entries.isEmpty || GanchoArchive.containsArchiveManifest(destination) else { throw CocoaError(.fileWriteFileExists) } } diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive+Restore.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive+Restore.swift index 96b48a41..f456b3bc 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive+Restore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive+Restore.swift @@ -74,6 +74,16 @@ extension GanchoArchive { return ValidatedArchive(root: root, rows: rows, blobs: blobs, limits: limits) } + /// Whether `directory` holds a manifest that decodes as a supported Gancho + /// manifest. Replacement uses it to tell a previous archive apart from a + /// foreign folder that happens to contain a `manifest.json`. + static func containsArchiveManifest(_ directory: URL) -> Bool { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let root = try? archiveRoot(directory) else { return false } + return (try? decodeManifest(in: root, with: decoder, limits: .production)) != nil + } + private static func decodeManifest( in root: ArchiveRoot, with decoder: JSONDecoder, limits: RestoreLimits ) throws -> Manifest { diff --git a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift index d1116774..06006ee0 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift @@ -66,6 +66,7 @@ public enum GanchoArchive { replacement: ReplacementPolicy, checkpoint: @Sendable (ExportCheckpoint) throws -> Void ) async throws -> Manifest { + try AtomicArchivePublication.requirePublishable(directory, replacement: replacement) let stage = try AtomicArchivePublication.makeStage(beside: directory) defer { try? FileManager.default.removeItem(at: stage) } try Task.checkCancellation() @@ -95,9 +96,12 @@ public enum GanchoArchive { // and no pretty-printing a JSON array is exactly `[`, its elements // joined by `,`, and `]`, and `ArchiveStreamingTests` pins that against // `encoder.encode(rows)` so the format and its checksum cannot drift. + // + // `directory` is the export's private stage, so every file is written + // in place: nothing in the selected destination changes until the + // whole stage is promoted, and a failure discards the entire stage. let clipsURL = directory.appendingPathComponent("clips.json") - let staged = directory.appendingPathComponent(".clips.json.\(UUID().uuidString)") - guard FileManager.default.createFile(atPath: staged.path, contents: nil) else { + guard FileManager.default.createFile(atPath: clipsURL.path, contents: nil) else { throw ArchiveError.corruptArchive("could not stage the export") } @@ -105,17 +109,8 @@ public enum GanchoArchive { // `@Sendable`, so state mutated across that boundary would not compile, // and keeping the handle and the hasher local is also what makes the // export a single pass with nothing held afterwards. - let streamed: (count: Int, blobs: Set, digest: String) - do { - streamed = try await streamRows( - from: store, to: staged, options: options, encoder: encoder) - } catch { - try? FileManager.default.removeItem(at: staged) - throw error - } - // This rename is private to the owned stage. Nothing in the selected - // destination changes until all files and their manifest are complete. - try AtomicFileReplace.publish(staged: staged, as: clipsURL) + let streamed = try await streamRows( + from: store, to: clipsURL, options: options, encoder: encoder) try checkpoint(.rows) try Task.checkCancellation() @@ -139,7 +134,7 @@ public enum GanchoArchive { throw ArchiveError.corruptArchive( "source store is missing or has a corrupt referenced blob") } - try data.write(to: blobDir.appendingPathComponent(hash), options: .atomic) + try data.write(to: blobDir.appendingPathComponent(hash)) checksums["blobs/\(hash)"] = digest } } @@ -153,7 +148,7 @@ public enum GanchoArchive { manifestEncoder.dateEncodingStrategy = .iso8601 manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] try manifestEncoder.encode(manifest) - .write(to: directory.appendingPathComponent("manifest.json"), options: .atomic) + .write(to: directory.appendingPathComponent("manifest.json")) try checkpoint(.manifest) return manifest } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift index 09907120..fed005af 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -105,6 +105,9 @@ struct ArchivePublicationTests { from: source, to: destination, replacement: .replaceExisting) let target = try makeStore(in: root, name: "restore-blobs") #expect(try await GanchoArchive.restore(from: destination, into: target).inserted == 1) + // The swapped-out archive lands in the stage, which is then cleaned. + let siblings = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) } @Test("An actual rename failure preserves the prior archive and recovery stage") @@ -154,6 +157,27 @@ struct ArchivePublicationTests { #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) } + @Test("Replacement refuses a folder whose manifest.json is not a Gancho manifest") + func replacementRefusesForeignManifest() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makeStore(in: root, name: "blobs") + let destination = root.appendingPathComponent("extension") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try Data(#"{"manifest_version":3,"name":"x","version":"1.0"}"#.utf8) + .write(to: destination.appendingPathComponent("manifest.json")) + try Data("source".utf8).write(to: destination.appendingPathComponent("background.js")) + let before = try snapshot(destination) + + await #expect(throws: CocoaError.self) { + try await GanchoArchive.export( + from: source, to: destination, replacement: .replaceExisting) + } + #expect(try snapshot(destination) == before) + let siblings = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!siblings.contains { $0.hasPrefix(".ganchoarchive-stage-") }) + } + @Test("Replacement accepts an empty folder, ignoring Finder metadata") func replacementAcceptsEmptyFolder() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -182,14 +206,16 @@ struct ArchivePublicationTests { flags == 0 ? AtomicArchivePublication.systemRename(source, destination, 0) : failure } + // A decodable manifest, so `.replaceExisting` passes the archive guard + // and reaches the unsupported swap itself. + let manifest = Data( + #"{"checksums":{},"clipCount":0,"exportedAt":"2026-01-01T00:00:00Z","version":1}"#.utf8) let fresh = root.appendingPathComponent("fresh.ganchoarchive") let stage = try AtomicArchivePublication.makeStage(beside: fresh) - try Data("fresh".utf8).write(to: stage.appendingPathComponent("manifest.json")) + try manifest.write(to: stage.appendingPathComponent("manifest.json")) try AtomicArchivePublication.publish( stage, as: fresh, replacement: .failIfExists, renamer: renamer) - #expect( - try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) - == Data("fresh".utf8)) + #expect(try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) == manifest) for policy in [GanchoArchive.ReplacementPolicy.failIfExists, .replaceExisting] { let second = try AtomicArchivePublication.makeStage(beside: fresh) @@ -200,8 +226,7 @@ struct ArchivePublicationTests { second, as: fresh, replacement: policy, renamer: renamer) } #expect( - try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) - == Data("fresh".utf8)) + try Data(contentsOf: fresh.appendingPathComponent("manifest.json")) == manifest) } } } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchiveStreamingTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchiveStreamingTests.swift index f9caf7c7..cd372bb4 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ArchiveStreamingTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchiveStreamingTests.swift @@ -184,16 +184,18 @@ struct ArchiveStreamingTests { _ = blobs // the store's blob dir is per-test; emptying it is enough try? FileManager.default.removeItem(at: store.blobsForMaintenance.directory) - let directory = FileManager.default.temporaryDirectory + // A private parent, so the export's sibling stage is observable. + let parent = FileManager.default.temporaryDirectory .appendingPathComponent("export-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: directory) } + defer { try? FileManager.default.removeItem(at: parent) } + let directory = parent.appendingPathComponent("backup.ganchoarchive", isDirectory: true) await #expect(throws: (any Error).self) { try await GanchoArchive.export(from: store, to: directory) } - let leftovers = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] - #expect( - !leftovers.contains { $0.hasPrefix(".clips.json.") }, - "a staging file survived: \(leftovers)") + // The rows streamed into the private stage; the failure discards that + // whole stage and never creates the destination. + let leftovers = (try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? [] + #expect(leftovers.isEmpty, "a staging artifact survived: \(leftovers)") } } diff --git a/docs/ARCHIVE_PUBLICATION.md b/docs/ARCHIVE_PUBLICATION.md index 44a8b471..4c5d0232 100644 --- a/docs/ARCHIVE_PUBLICATION.md +++ b/docs/ARCHIVE_PUBLICATION.md @@ -16,10 +16,12 @@ Select `.replaceExisting` only after obtaining a replacement decision for a user-owned destination, or for a temporary destination owned by the application. It replaces the complete directory, including files not declared in its manifest; it is not a merge into an arbitrary directory. Replacement is therefore limited to -a directory that is already a Gancho archive (it contains `manifest.json`) or is -empty apart from Finder's `.DS_Store`. Any other existing folder — for example one -named in a save panel by mistake — is refused untouched with a -"file exists" error. +a directory that is already a Gancho archive (its `manifest.json` decodes as a +supported Gancho manifest) or is empty apart from Finder's `.DS_Store`. Any other +existing folder — for example one named in a save panel by mistake, including one +that merely holds an unrelated `manifest.json` — is refused untouched with a +"file exists" error. The destination is checked before any export work starts, +and again at promotion time. Current production call sites: @@ -28,9 +30,10 @@ Current production call sites: replacement decision - iOS: `Apps/GanchoiOS/IOSAppModel.swift`, `makeBackupArchive()`, passes `.replaceExisting` for its fixed application-owned temporary archive, before - the system file exporter presents the user's final Files destination. A - leftover at that temporary path without a manifest (an interrupted in-place - export from an older build) is removed first, since replacement would refuse it + the system file exporter presents the user's final Files destination. Anything + left at that app-owned temporary path (an earlier export, or an interrupted + in-place export from an older build) is removed first, so a failed export never + leaves a stale plaintext archive there - CLI: `Packages/GanchoKit/Sources/gancho/GanchoCLI.swift`, `runExport()`, produces plain JSON or CSV through `exportJSON`/`exportCSV`. It does not call `GanchoArchive.export` or produce a portable archive, so this directory @@ -39,7 +42,7 @@ Current production call sites: ## Ownership and failure behavior The exporter builds the complete result in a private, uniquely named sibling -stage. It verifies the stored manifest and streams file checksum validation with +stage, writing each file in place because the whole stage is discarded on failure. It verifies the stored manifest and streams file checksum validation with bounded memory. Cancellation is checked between export phases, per blob, while validating, and before publication; the row stream inside GRDB's read is cancelled by GRDB itself. None of those operations modifies the selected destination. @@ -62,7 +65,8 @@ that an already published backup failed. The regression suite checks byte-for-byte preservation of an existing archive and an unrelated sentinel, restoreability, cancellation, missing source blobs, -injected row/blob/manifest/promotion failures, explicit replacement refusal, +injected row/blob/manifest/promotion failures, explicit replacement refusal +(including a foreign folder holding an unrelated `manifest.json`), successful complete replacement, and the real Darwin rename error path using isolated fixture directories. No live backup or Keychain is used.