Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
83 lines
4.8 KiB
Swift
83 lines
4.8 KiB
Swift
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)
|
|
)
|
|
}
|
|
}
|