import Foundation // MARK: - UnwritableCause /// **Why** a board's location refuses writes — the half of `ReadOnlyLockReason.unwritableLocation` /// the user is actually told about (02-architecture.md § Write-failure surfacing, settled: "the /// probe distinguishes read-only volume from permission-denied folder and the lock reason carries /// it … the fixes being different acts"). /// /// Two cases and no `.unknown`: the probe below is total — it asks two questions of the filesystem /// and every answer maps onto one of these or onto "writable". A third case would be a shrug in the /// one place the design forbids one, since the whole point of naming the cause is that ejecting a /// DMG and running `chmod` are not the same repair. public enum UnwritableCause: String, Sendable, Equatable, CaseIterable { /// The volume the board sits on is mounted read-only — a DMG, an APFS snapshot, a read-only /// share. Nothing on it can be written, by anybody, until it is remounted. case readOnlyVolume /// The volume is writable; this *folder* is not — POSIX permissions, an ACL, an owner that is /// not us. A `chmod`, a Get Info panel, or a move somewhere else fixes it. case permissionDenied } // MARK: - WritabilityProbe /// The board root's writability, asked of the filesystem and classified — the open-time probe of /// 02-architecture.md § Write-failure surfacing ("An unwritable board location enters the read-only /// lock at open"), re-run on every reconciling reload because that rule is **symmetric**: a fixed /// permission or a rewritable remount clears the lock, and a volume gone read-only mid-session /// raises it. /// /// ### Two questions, and the order they are asked in matters /// /// `FileManager.isWritableFile(atPath:)` is `access(2)` with `W_OK`: a real-uid permission question /// answered by the filesystem itself, which is why it is trustworthy where a stat of the mode bits /// would not be (ACLs, sandbox denials, and read-only mounts all show up in it). But it answers /// `false` for **both** halves of this case — a read-only volume fails `access(2)` exactly like a /// `r-x` folder does — so on its own it can only say "no", never "why". /// /// `URLResourceKey.volumeIsReadOnlyKey` is what separates them, and it is therefore asked **first**. /// The precedence is not a tie-break: a board on a mounted DMG *is* on a read-only volume and it is /// also permission-denied by `access(2)`, and telling the user "you don't have permission to change /// this folder" would send them to a Get Info panel that cannot help. The volume's answer is the /// more fundamental fact and it names the repair that works, so it wins whenever it is `true`. /// /// A volume that will not answer at all (`nil` — an exotic filesystem, a URL whose volume is gone) /// falls through to `access(2)`, which is the honest degradation: we still know whether the board /// can be written, we just describe it as the folder's doing. /// /// ### Why the classifier is separate from the I/O /// /// `classify(volumeIsReadOnly:isWritable:)` is a pure function of the two answers, so the truth /// table — including the read-only-volume-that-somehow-passes-`access` row, which `root` can /// produce — is testable without a DMG. `probe(_:)` is only the two syscalls and this call. public enum WritabilityProbe { /// The cause, or `nil` when the location accepts writes. /// /// Deliberately optional-returning rather than `Bool`-returning: every caller either raises a /// lock carrying the cause or clears one, and a `Bool` would force a second question at each /// site to find out which line to show. public nonisolated static func classify(volumeIsReadOnly: Bool?, isWritable: Bool) -> UnwritableCause? { if volumeIsReadOnly == true { return .readOnlyVolume } return isWritable ? nil : .permissionDenied } /// Asks the filesystem about `root` and classifies the answer. /// /// **The URL is rebuilt from its path** before the resource value is read. `URL` bridges to /// `NSURL`, which caches resource values it has already been asked for — and this probe's whole /// job is to notice that an answer *changed* since last time. A cached `volumeIsReadOnly` would /// make the symmetric rule silently one-way again, in a way no test on a fresh URL would catch. /// `access(2)` has no such cache, which is why only the resource value needs the ceremony. public nonisolated static func probe(_ root: URL) -> UnwritableCause? { let fresh = URL(fileURLWithPath: root.path, isDirectory: true) let volumeIsReadOnly = (try? fresh.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly return classify( volumeIsReadOnly: volumeIsReadOnly, isWritable: FileManager.default.isWritableFile(atPath: root.path) ) } }