import Foundation /// **The one place this app creates a `.zip`** — `/usr/bin/ditto -c -k`, spawned as a child /// process, for File ▸ Share… (`BoardShareStager`; design ruling 2026-08-09, card 72691b11). /// /// ### Why a spawned system tool rather than a library or a framework /// /// Foundation has no public zip-writing API, and the two frameworks that could stand in for one /// both miss: `AppleArchive` (macOS 11+) writes `.aar`/LZFSE streams, not the `.zip` format every /// share destination this card targets (Mail, Messages, AirDrop, Finder) actually expects to open; /// pulling in a third-party archiver (libarchive, minizip, …) is real dependency weight for one /// button. `ditto -c -k` is Apple's own documented recipe for "make a zip archive" outside of /// Finder's Compress menu, and it is what Finder's own Compress item shells out to — so a `.zip` /// this produces is the same bytes a user's own Finder compress would have written. /// /// ### The sandbox question, answered rather than assumed /// /// A sandboxed app's `Process` spawns a **child that inherits the parent's sandbox** rather than /// running unconfined — the concern the card's design ruling asks to "VERIFY in a sandboxed /// build" rather than take on faith. `/usr/bin/ditto` is a platform binary reading and writing /// only paths this app's own process already owns (`BoardShareStager`'s staging directory, inside /// the app's temporary directory — no security-scoped bookmark, no path outside the sandbox the /// process wasn't already free to touch), so there is no privilege the child needs that the parent /// does not already have. **Verified empirically, not just argued**: `DittoZipArchiverTests` runs /// this exact call inside `KanbanTests`, which is host-application-hosted /// (`TEST_HOST = .../Lanework.app/...` in `project.yml`) and therefore runs *as* the sandboxed app /// process, entitlements included — the closest this suite can get to the real Share button /// without driving AppKit. A green run there is the sandboxed build the ruling asked for. /// /// If a future OS or entitlement change ever makes that test go red, the ruling's own fallback is /// recorded at the call site that would need it (`BoardShareStager`'s doc comment): share the /// board's **folder URL** directly rather than a zip, which needs no subprocess at all. enum DittoZipArchiver { /// Why the archive did not get made — `BoardShareStager` folds both into its own `.failed`. enum Failure: Error, Sendable, Equatable { /// `Process.run()` itself threw — the executable could not be launched at all (missing, /// unreadable, or a sandbox denial of the spawn itself rather than of what runs inside it). case launchFailed(String) /// `ditto` ran and exited non-zero — its own stderr, which names the actual reason (a /// source that vanished mid-copy, a destination it could not create) far better than this /// type could reword it. case nonZeroExit(status: Int32, message: String) } /// Zips the **contents** of `source` into `destination` — `--keepParent` so the archive's one /// top-level entry is `source`'s own folder name (a recipient decompressing the `.zip` gets /// back a folder named after the board, not its contents spilled loose), and /// `--sequesterRsrc` so any resource forks or Finder metadata land in `__MACOSX/` alongside /// the payload rather than corrupting it for a non-Apple unzip on the receiving end — the same /// two flags Finder's own Compress uses. /// /// `destination`'s parent must already exist; `ditto` does not create intermediate /// directories, and this call does not either — `BoardShareStager` owns the staging /// directory's lifecycle, this only ever writes one file into a folder that is already there. /// /// Synchronous and blocking — callers running this off the main actor (`BoardShareStager`, /// via `Task.detached`) is what keeps a multi-hundred-megabyte board's zip from freezing a /// spinner, the same posture `BoardTreeCopy`'s own doc comment states for the plain-copy half /// of this same flow. static func zip(contentsOf source: URL, to destination: URL) throws(Failure) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") process.arguments = ["-c", "-k", "--sequesterRsrc", "--keepParent", source.path, destination.path] // Piped rather than inherited: a child's stdout would otherwise interleave with this // process's own on the same terminal/log, and stderr is read back on a non-zero exit, // which is the only time `ditto`'s own words are worth surfacing. process.standardOutput = Pipe() let stderrPipe = Pipe() process.standardError = stderrPipe do { try process.run() } catch { throw .launchFailed(error.localizedDescription) } process.waitUntilExit() guard process.terminationStatus == 0 else { let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) throw .nonZeroExit(status: process.terminationStatus, message: message) } } }