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..bdb310c9 100644 --- a/Apps/GanchoiOS/IOSAppModel.swift +++ b/Apps/GanchoiOS/IOSAppModel.swift @@ -947,13 +947,17 @@ 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: 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. 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..6462ece5 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/AtomicArchivePublication.swift @@ -0,0 +1,96 @@ +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 { + /// 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) + 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, + renamer: Renamer = AtomicArchivePublication.systemRename + ) throws { + 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. + } + + /// 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 (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 { + throw CocoaError(.fileWriteFileExists) + } + let entries = try FileManager.default.contentsOfDirectory(atPath: destination.path) + .filter { $0 != ".DS_Store" } + guard entries.isEmpty || GanchoArchive.containsArchiveManifest(destination) else { + throw CocoaError(.fileWriteFileExists) + } + } + + static let systemRename: Renamer = { source, destination, flags in + source.withUnsafeFileSystemRepresentation { sourcePath in + destination.withUnsafeFileSystemRepresentation { destinationPath in + 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+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 6f2419e7..06006ee0 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,36 @@ 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 FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + 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 AtomicArchivePublication.requirePublishable(directory, replacement: replacement) + 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] @@ -61,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") } @@ -71,20 +109,10 @@ 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 - } - // 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. - 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() let clipCount = streamed.count let referencedBlobs = streamed.blobs @@ -95,6 +123,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") @@ -105,11 +134,13 @@ 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 } } + try checkpoint(.blobs) + try Task.checkCancellation() let manifest = Manifest( version: currentVersion, exportedAt: .now, clipCount: clipCount, checksums: checksums) @@ -117,10 +148,37 @@ 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 } + private static func validateExport(_ manifest: Manifest, in directory: URL) throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + 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") } + 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() + hasher.update(data: bytes) + } + return hex(hasher.finalize()) + } + // MARK: - Restore (merge with dedupe; transactional rollback) @discardableResult diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift new file mode 100644 index 00000000..fed005af --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ArchivePublicationTests.swift @@ -0,0 +1,232 @@ +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)") + 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-") }) + } + } + + @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) + // 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") + 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) + } + + @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 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) + 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 + } + + // 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 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")) == manifest) + + 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")) == 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 new file mode 100644 index 00000000..4c5d0232 --- /dev/null +++ b/docs/ARCHIVE_PUBLICATION.md @@ -0,0 +1,75 @@ +# 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. Replacement is therefore limited to +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: + +- 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. 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 + 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, 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. + +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. 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. + +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 +(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. + +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.