Skip to content
Merged
7 changes: 4 additions & 3 deletions Apps/GanchoMac/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion Apps/GanchoiOS/IOSAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions Packages/GanchoKit/Sources/GanchoKit/GanchoArchive+Restore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
98 changes: 78 additions & 20 deletions Packages/GanchoKit/Sources/GanchoKit/GanchoArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -61,30 +96,23 @@ 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")
}

// Everything the stream touches lives INSIDE the read closure: it is
// `@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<String>, 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
Expand All @@ -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")
Expand All @@ -105,22 +134,51 @@ 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)
let manifestEncoder = JSONEncoder()
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
Expand Down
Loading
Loading