diff --git a/docs/RESUME-audit-3-macos.md b/docs/RESUME-audit-3-macos.md index 6abacb17..b0c65d27 100644 --- a/docs/RESUME-audit-3-macos.md +++ b/docs/RESUME-audit-3-macos.md @@ -105,3 +105,18 @@ First Aid in Mini vMac, the image is built. Step 5: 152 s, 2.35 GB peak (`docs/build-memory-crashes.md`). This was the last leg; the audit's Low items live in the findings list only. The HFS follow-ups and the hardware checks continue in `docs/RESUME-hfs-snow.md`. + +Hardware close-out, 2026-09-08: all four remaining checks PASS — R6 against +an SD card held read-only by its lock switch (read path escalates read-only +and says why; `restore --device --yes` refuses with the write-path message +before touching the device), R11 against a live cancel on the USB floppy +(one dialog, no read-only retry, and on the `O_RDWR` path where the +`!is_authorization_cancelled` guard actually runs), R19 against the floppy +itself (1474560 bytes and an HFS superfloppy, not `0 B`), and section 3's +read-only fallback via an authopen timeout rather than the predicted +mounted-card EBUSY. Getting there needed two new fixes: R-068 (the CLI +never elevated for a raw device) and R-069 (a content probe opened the +device before the elevation path could run, which made R-068's fix +unreachable). The TEMP-DIAG instrumentation was gated on R6 and is now +removed. Details and exact log lines are in the verification table in +`docs/Regression_Bugs.md`. diff --git a/docs/RESUME-macos-hardware-audit.md b/docs/RESUME-macos-hardware-audit.md new file mode 100644 index 00000000..6ef8da25 --- /dev/null +++ b/docs/RESUME-macos-hardware-audit.md @@ -0,0 +1,215 @@ +# RESUME: close the four macOS hardware checks + +> **DONE 2026-09-08. All four checks PASS.** Kept for the reproductions and +> the hardware notes; nothing here is outstanding. Results and the exact log +> lines are in the verification table in `docs/Regression_Bugs.md`, and the +> narrative is in the status block at the end of `docs/RESUME-audit-3-macos.md`. +> +> Two fixes were needed before any check could run: **R-068** (the CLI never +> elevated for a raw device) and **R-069** (the encrypted-DMG probe opened the +> device with a plain `File::open` before the raw-device check, so R-068's fix +> never ran). The floppy turned out to be readable after all — the 800K disk +> below could not be read, but a 1.44MB Mac-formatted one was. TEMP-DIAG has +> been removed. + +Everything in the 2026-09-01 audit is shipped and merged. Four verification +checks remain, and every one of them needs removable media and a human at the +keyboard — they cannot be run unattended, which is why they have survived two +sessions. This prompt exists to close them. + +Prior context: `docs/RESUME-audit-3-macos.md` (the audit leg itself), +`docs/Regression_Bugs.md` (the record; the verification table near the bottom +holds the four rows to update). + +## Read this before you start + +**The blocker was fixed on 2026-09-08.** R-068: `open_source_for_reading` — +the function that escalates through `authopen`, recognises write-protected +media (R-051) and decodes a cancelled dialog (R-052) — had four callers, all +in the GUI. The CLI's two device sites in `src/model/source_reader.rs` reached +the device with a plain `File::open`, so on macOS, where privilege is escalated +per operation and never inherited by the process, `rb-cli` could only touch a +raw device under `sudo`. Both sites now go through `open_source_for_reading`. + +Two consequences for this work: + +1. The R19 reproduction command in `docs/RESUME-audit-3-macos.md` **could not + have worked** unprivileged before that fix. That is why the check never ran + on 2026-09-05, not any property of the drive. +2. The user recalls rusty-backup reading this floppy successfully in the past. + That was almost certainly the **GUI**, which has always had the authopen + path. Treat "it worked before" as evidence about the GUI, not the CLI, and + not as evidence that the drive is currently healthy. + +## Setup + +The release binary is stale as of this writing — rebuild first: + +```bash +cd /Users/dani/repos/rusty-backup && cargo build --release --bin rb-cli +``` + +Do not run two cargo builds at once, and do not raise the job count or +debuginfo (`.cargo/config.toml` caps both; see `docs/build-memory-crashes.md`). + +Confirm the media is actually attached before anything else: + +```bash +diskutil list external && diskutil info diskN | grep -iE 'read-only|removable|media name|disk size' +``` + +`Media Read-Only: Yes` means the write-protect tab is open — that is the R6 +condition, so note it now. + +### Hardware gotchas that cost the last session + +- **The drive vanishes.** On 2026-09-08 `/dev/disk5` and the whole USB floppy + device disappeared from the bus mid-session; `diskutil list external` + returned nothing at all. A read then fails with **ENXIO (os error 6), + "Device not configured"** — which reads like a driver refusing the transfer + but is really "there is no device". Re-seat the drive and re-check + `diskutil list external` before believing any read failure. It did this four + more times across the 2026-09-08 session, twice while rusty-backup held the + DA claim and once between a `diskutil list` and the `diskutil info` a second + later; ENOENT (os error 2) is the same event caught at open() instead. We + issue no eject — only DA unmount and claim — so treat it as the drive, but + expect any single hardware run to be interrupted by it. +- **ENXIO is not EIO.** R-053's original note recorded EIO at sector 0. If you + get ENXIO instead, suspect the bus, not the read path. +- **800K Mac floppies cannot be read at all** by a PC USB floppy drive: they + are GCR-encoded with variable speed. Only 1.44MB MFM ("SuperDrive"-format) + Mac floppies work. The drive advertises 2880 x 512 for any disk, so the + reported size tells you nothing. If this disk is an 800K, R19 cannot be + closed on this hardware and the row should say so rather than staying open + forever. + +## The four checks + +Run each, then update its row in the verification table in +`docs/Regression_Bugs.md` (the rows currently reading "pending hardware") and +strike the matching summary row if it is now fully verified. + +### 1. R19 / R-053 — the USB floppy raw read + +```bash +./target/release/rb-cli --log-level debug inspect /dev/rdiskN +``` + +Expect: the real size (a 1.44MB floppy is 1474560 bytes / 2880 sectors), not +`0 B`. If the drive refuses a multi-sector read, expect exactly one warning: + +``` +a -byte read failed (...) but one sector reads fine; continuing one sector at a time +``` + +That line is `SectorAlignedReader::read_after_refusal` in `src/os/mod.rs`. Its +absence is fine — it only fires on a drive that refuses large reads. What +matters is that the size is right and the partition table or HFS volume is +detected. + +If it fails, get a baseline before blaming our code: + +```bash +sudo dd if=/dev/rdiskN bs=512 count=1 of=/dev/null && sudo dd if=/dev/rdiskN bs=8192 count=1 of=/dev/null +``` + +512 works and 8192 fails is the R-053 shape. Both failing is the drive or the +media (see the gotchas above). + +Since this disk is HFS, also confirm the volume actually opens: + +```bash +./target/release/rb-cli ls /dev/rdiskN +``` + +### 2. R6 / R-051 — write-protected media + +Needs media whose lock is engaged: an SD card with the lock switch down, or +this floppy with its write-protect tab open (`Media Read-Only: Yes`). + +```bash +./target/release/rb-cli --log-level debug backup /dev/rdiskN /tmp/r6-backup +``` + +Expect the read path to log, verbatim: + +``` + is write-protected (lock switch or read-only image); opened read-only, a restore to it cannot work +``` + +with **no second authorization prompt**, and the backup completing read-only. +Then confirm a restore refuses *before* it unmounts anything — it must not +touch the device first. The write path has its own, different message: + +``` + is write-protected (media lock switch or read-only image); it cannot be written to +``` + +The code is `da_media_writable` / `DKIOCISWRITABLE` in `src/os/macos.rs` +(the ioctl at ~127, the read-path log at ~925, the write-path bail at ~1468). + +### 3. R11 / R-052 — a cancelled authorization dialog + +This is now reachable from the CLI (it was GUI-only before R-068). With no +cached descriptor for the device — a fresh process, since `ELEVATED_DEVICES` +caches per device per session — run the inspect and **click Cancel**: + +```bash +./target/release/rb-cli --log-level debug inspect /dev/rdiskN +``` + +Expect: the log says the administrator authorization was **cancelled**, and +**no second prompt** — the read-only retry must be skipped, because a cancel is +the user's answer. Getting a second dialog, or an error mentioning "no +ancillary control message", is the R-052 regression. + +### 4. Section 3 (63e8d3f) — read-only escalation fallback + +Needs media that is **mounted** while it is opened, so an SD card or USB stick +formatted FAT/exFAT — the HFS floppy will not mount on modern macOS and cannot +exercise this path. + +Insert it, let macOS mount it, then open it in the GUI's Inspect tab. Expect: + +``` +read-write escalation of /dev/rdiskN failed (...); retrying read-only +``` + +Inspect should then succeed read-only, browse should work, and edit mode should +say why it is unavailable rather than failing obscurely. + +## Then: remove TEMP-DIAG + +`describe_device_access` and its call sites were added to diagnose a macOS +restore failing with `Permission denied` at the very end of an otherwise +successful run. That was diagnosed and fixed on 2026-08-05. The instrumentation +was kept only until the fix was confirmed on hardware — which is check 2 above. + +**Once R6 passes on real write-protected media, delete all eight sites:** + +```bash +grep -rn 'TEMP-DIAG' src/ +``` + +- `src/os/mod.rs:195` — `describe_device_access` itself (and its doc comment at + 200) +- `src/os/macos.rs:936` — `probe_device_access` +- `src/os/macos_stub.rs:346` — the stub counterpart +- `src/gui/backup_tab.rs:2591`, `src/gui/inspect_tab.rs:2885`, + `src/gui/restore_tab.rs:1850` — the three call sites + +Removing the function means the `#[allow(unused_variables)]` on it goes too. +Check `cargo clippy --all-targets -- -D warnings` after. + +## Close-out + +- Update the four rows in the verification table in `docs/Regression_Bugs.md`, + and strike R-051 / R-053 in the summary table if their hardware halves now + pass. Record a genuine "cannot be tested on this hardware" verdict if the + floppy turns out to be 800K — an untestable check should be closed with a + reason, not left pending a third time. +- Update the status block at the bottom of `docs/RESUME-audit-3-macos.md`. +- Engine code must still compile on Rust 1.73 — see CONTRIBUTING.md. The modern + build will not catch a violation, and clippy's autofixes postdate 1.73. +- `bash scripts/preflight.sh`, then commit. Integration is by **pull request** + against `main`; never merge locally. diff --git a/docs/Regression_Bugs.md b/docs/Regression_Bugs.md index e0939326..02daa939 100644 --- a/docs/Regression_Bugs.md +++ b/docs/Regression_Bugs.md @@ -19,6 +19,8 @@ finding depends on a fixture, the fixture is named. | ID | Severity | Area | Finding | |----|----------|------|---------| +| ~~[R-070](#r-070)~~ | ~~**High**~~ **FIXED** | `src/cli/verbs/restore.rs` | ~~`rb-cli restore` without `--device` treats a `/dev/...` target as an image file and create/truncates it, so a writable device is written raw with no safety preflight, no system-disk guard, no unmount, no disk claim and no write-protect check~~ — a device-shaped target without `--device` is now refused, 2026-09-08 | +| ~~[R-069](#r-069)~~ | ~~**High**~~ **FIXED** | `src/model/source_reader.rs` | ~~The encrypted-DMG probe opens the source with a plain `File::open` before the raw-device check is reached, so on macOS an unprivileged `rb-cli` device verb still dies with a bare `Permission denied` and R-068's fix never runs~~ — the device check now precedes every content probe, 2026-09-08 | | ~~[R-068](#r-068)~~ | ~~**High**~~ **FIXED** | `src/model/source_reader.rs`, `src/os/mod.rs` | ~~The CLI opens a raw device with a plain `File::open` and never elevates, so on macOS — where privilege is escalated per operation through `authopen`, not inherited from the process — every unprivileged `rb-cli` device verb dies with a bare `Permission denied` and no way forward~~ — the CLI device path goes through `open_source_for_reading` like the GUI's, holding the disk claim for the reader's lifetime, 2026-09-08 | | ~~R-067~~ | ~~Medium~~ **FIXED** | `src/fs/xfs/fsck.rs` | ~~`rb-cli fsck` reports the bmap-btree blocks of a btree-format inode as leaked (`UnaccountedBlocks`) on a volume `xfs_repair -n` accepts~~ — the block census claimed the fork's extents but not the tree's own blocks, 2026-09-06 | | ~~R-066~~ | ~~Medium~~ **FIXED** | `src/fs/xfs/freespace_rebuild.rs` | ~~`sb_fdblocks` falls below the free count `xfs_repair` derives once a volume's free-space btrees grow past their roots (`sb_fdblocks 232894, counted 232904` after 200000 files)~~ — the resync omitted `agf_btreeblks`, 2026-09-06 | @@ -34,9 +36,9 @@ finding depends on a fixture, the fixture is named. | ~~R-056~~ | ~~**High**~~ **FIXED** | `src/fs/hfsplus.rs` | ~~An in-place HFS+ grow patches two counts and nothing else: the old alternate-header block stays marked, the new one is not, and the allocation file never grows; Disk First Aid reports orphaned blocks and under-allocation on every grown or Minimum-restored volume~~ — the resize moves the header's blocks, grows or relocates the allocation file and recounts, 2026-09-05 | | ~~R-055~~ | ~~Medium~~ **FIXED** | `src/fs/hfsplus.rs`, `src/fs/hfs_common.rs` | ~~Deleting the last fragmented file leaves the HFS+ extents-overflow tree as a root leaf with no records; Disk First Aid stops at "Invalid node structure"~~ — an emptied leaf leaves the tree and the last one retires the root, 2026-09-05 | | ~~R-054~~ | ~~**High**~~ **FIXED** | `src/fs/hfs_common.rs`, `src/fs/hfsplus.rs`, `src/fs/hfs.rs` | ~~Deleting a leaf's first record leaves its parent's separator on the old key; Disk First Aid reports "Invalid index key" on every HFS+ volume rb-cli deleted from~~ — separators are refreshed up the tree, fsck checks equality, 2026-09-05 | -| ~~R-053~~ | ~~**High**~~ **FIXED** | `src/os/mod.rs`, `src/model/source_reader.rs` | ~~`rb-cli inspect` on a raw device reads through a plain `BufReader`: unaligned 8 KiB reads, every device reported as 0 B, and a USB floppy drive fails with EIO at sector 0 (audit R19)~~ — devices go through `SectorAlignedReader`, which drops to one sector per read once a larger read is refused, 2026-09-05; floppy confirmation pending | +| ~~R-053~~ | ~~**High**~~ **FIXED** | `src/os/mod.rs`, `src/model/source_reader.rs` | ~~`rb-cli inspect` on a raw device reads through a plain `BufReader`: unaligned 8 KiB reads, every device reported as 0 B, and a USB floppy drive fails with EIO at sector 0 (audit R19)~~ — devices go through `SectorAlignedReader`, which drops to one sector per read once a larger read is refused, 2026-09-05; confirmed on the USB floppy drive 2026-09-08 | | ~~R-052~~ | ~~Medium~~ **FIXED** | `src/os/macos.rs` | ~~A cancelled authorization dialog is reported as "no ancillary control message" and falls through to a second prompt (audit R11)~~ — authopen's two-byte reply and stderr are decoded; ECANCELED is the user's answer, 2026-09-05 | -| ~~R-051~~ | ~~Medium~~ **FIXED** | `src/os/macos.rs` | ~~A write-protected card's EACCES is taken for missing privilege: the read path prompts read-write, fails, prompts again; the write path blames sudo (audit R6)~~ — read-only tried directly, DKIOCISWRITABLE / DAMediaWritable name the media, 2026-09-05; lock-switch check on hardware pending | +| ~~R-051~~ | ~~Medium~~ **FIXED** | `src/os/macos.rs` | ~~A write-protected card's EACCES is taken for missing privilege: the read path prompts read-write, fails, prompts again; the write path blames sudo (audit R6)~~ — read-only tried directly, DKIOCISWRITABLE / DAMediaWritable name the media, 2026-09-05; confirmed on a lock-switched SD card 2026-09-08 | | ~~R-050~~ | ~~**High**~~ **FIXED** | `src/fs/ntfs.rs` | ~~Every MFT record rb-cli assembles carries a bytes-in-use four bytes short; chkdsk corrects the first free byte of each created file and directory~~ — the end marker counts as eight bytes, 2026-09-03 | | ~~R-049~~ | ~~**High**~~ **FIXED** | `src/fs/ntfs.rs` | ~~Every long name rb-cli writes to NTFS is a lone Win32-namespace name, which NTFS allows only beside a DOS alias; chkdsk reports minor file name errors~~ — POSIX namespace, as Windows writes with 8.3 creation off, 2026-09-02 | | ~~R-048~~ | ~~**High**~~ **FIXED** | `src/fs/ntfs.rs` | ~~A renamed NTFS entry's index entry carries the old name's creation snapshot and a raw byte count; chkdsk reports minor file name errors and an incorrect `$I30` entry~~ — the copies take the record's live times and the data attribute's real and allocated sizes, 2026-09-02 | @@ -239,9 +241,11 @@ and callers that ask for a size still get it (4-block floor). ## Found during the 2026-09-01 audit, leg 3 (macOS), 2026-09-05 The macOS leg's three findings, each with the cause the fix rests on. No -removable hardware was attached during the run, so the hardware halves of -R-051 and R-053 (an SD card's lock switch, the USB floppy drive) stay open; -both fixes were exercised against `hdiutil`-attached raw images instead. +removable hardware was attached during the run, so both fixes were exercised +against `hdiutil`-attached raw images at the time. The hardware halves of +R-051 and R-053 closed on 2026-09-08 against a lock-switched SD card and the +USB floppy drive; getting there took two further fixes, R-068 and R-069, +because the CLI could not reach a raw device unprivileged at all. ### R-051 — a write-protected card is treated as a privilege problem (audit R6) {#r-051} @@ -298,6 +302,76 @@ read-only retry is skipped after a cancel. Unit tests cover the decode; the live cancel on this machine is pending the user, since raising the dialog unattended was not an option during the run. +### R-070 — a restore without `--device` writes a device as if it were a file {#r-070} + +**FIXED 2026-09-08** (`fix(cli): a device-shaped restore target requires +--device`). Found while running the R6 write-path check on hardware. + +`rb-cli restore /dev/disk4` — no `--device` — answered + +``` +error: restore failed: failed to create /dev/disk4: Permission denied (os error 13) +``` + +rather than the write-protect message R6 expects. The bare EACCES, and the +absence of the device branch's `cannot open ... for writing` context, place the +failure in `run_restore`'s *image-file* arm: `RestoreConfig::target_is_device` +comes straight from the `--device` flag, so a `/dev/...` path without it is +opened with `OpenOptions::create(true).truncate(true)`. + +The card was write-protected, which is the only reason this stopped. On a +writable device — or under `sudo`, where the node's permissions do not bite — +that arm opens the raw device and writes to it, while `O_TRUNC` is silently +ignored on a device node. Everything the device path exists to do is skipped: +`device_safety::preflight` and its system-disk guard, the DiskArbitration +unmount, the exclusive disk claim, the sector-aligned writer, and +`open_target_for_writing`'s write-protect bail. `--device`'s own help text +advertises exactly those as what the flag enables, so omitting it disables +them while still performing the write. + +`is_device_path` already existed to recognise the shape. A restore whose target +matches it but that was not given `--device` is now refused, naming the flag. +Auto-enabling device mode was rejected: it would turn a typo into a +whole-disk overwrite. + +### R-069 — a content probe opens a raw device before the elevation path {#r-069} + +**FIXED 2026-09-08** (`fix(cli): the raw-device check precedes every content +probe`). Found on the first run of the R-053 floppy check, against a binary +that already carried R-068's fix. + +`rb-cli --log-level debug inspect /dev/rdisk5` on an unprivileged shell still +answered + +``` +error: open /dev/rdisk5: Permission denied (os error 13) +``` + +with no authorization dialog and no log output at all — the same symptom +R-068 was supposed to have retired hours earlier. + +R-068 moved the raw-device check above the `File::open` that had preceded it, +but only the one at the *end* of `open_peeled_read_with_entry`. The +encrypted-DMG probe sits earlier in that same function, inside +`#[cfg(feature = "crypto")]` — on by default — and opens the source +unconditionally: + +```rust +let mut probe = File::open(path).with_context(|| format!("open {}", path.display()))?; +``` + +That `?` fires on a root-owned device node and returns before the device check +is reached, so `open_source_for_reading` never runs. The total absence of log +output was the tell: the failure landed before the first `log::` call on the +device path, which is why the error carried no `authopen` context. + +The check now sits immediately after `is_container_path`, above every content +probe — a raw device is never a container, an encrypted DMG or an NDIF fork +carrier, so none of those probes has anything to say about one. +`try_decode_dart` escaped the same bug only by accident: it reads +`metadata(path).len()`, which is 0 for a character device, so it returns early +under its own 84-byte floor. + ### R-068 — the CLI never elevates for a raw device {#r-068} **FIXED 2026-09-08** (`fix(cli): a raw device opens through the platform's @@ -552,10 +626,10 @@ PNGs under `docs/evidence/`. | Section 5 of `docs/RESUME-hfs-snow.md` | B-tree header attributes | our HFS+ trees carried `attributes = 0`; Apple writes `kBTBigKeysMask \| kBTVariableIndexKeysMask` (6) on the catalog and attributes trees and `kBTBigKeysMask` (2) on the extents tree. `write_blank_btree_header_node` (blank volumes and the defragmenting clone) now does the same; H1 / H3 / H5 / H7-hfsplus re-run | **PASS** (2026-09-05): `fsck_hfs -n` clean on all, 1500 of 1500 files identical through the kernel driver; the bits read back 2 / 6 / 6 | out of scope (HFS+) | | Section 7 of `docs/RESUME-hfs-snow.md` | a real-Mac-formatted volume edited by rb-cli | the System 7.1 Finder initializes a blank 5 MiB Apple_HFS partition inside Snow (`scripts/verify-hfs-snow.sh mac-formatted`); rb-cli then `put`s a text file and a binary, `mkdir`s, `mv`s, `rm`s, `setrsrc`s, and `put-binhex`es Disk First Aid; `rb-cli fsck`, `fsck_hfs -n`, Disk First Aid, and the Finder judge it | **PASS** (2026-09-05): `fsck_hfs -n` OK before and after the edits (Mac OS laid the volume out at 512-byte blocks, `drAlBlSt` 6, filling the partition, which our new `AllocationAreaEnd` check accepts) | **PASS**: "The volume snow71 appears to be OK." (`docs/evidence/dfa-mac-formatted.png`); TeachText shows the text file (`finder-mac-formatted-hi.png`) and the Finder launches the rb-cli-written Disk First Aid from that volume, resource fork intact (`finder-mac-formatted-dfa-launch.png`) | | H12 | the 1000-file churn with the OS taking the middle turn | rb-cli imports 1000 files; the OS adds one file, deletes it, deletes the 1000; rb-cli adds one more; `fsck_hfs -n` and `rb-cli fsck` after every turn. HFS+: macOS's kernel driver through a read-write mount (`verify-fs-macos.sh -o H12-hfsplus`). Classic HFS: the System 7.1 Finder in Snow, ending with Shut Down so the MDB is flushed (`verify-hfs-snow.sh os-churn`) | HFS+: **PASS** (2026-09-05), OK after each of the three turns, the last file reads back through the kernel driver. HFS: **PASS** after the Finder's turn and after rb-cli's put | HFS: **PASS** both times (`docs/evidence/dfa-h12-finder.png`, `dfa-h12-after.png`). A first run judged the volume before Mac OS had flushed its MDB and drew "needs to be repaired" with stale counts; that frame is the `scripts/snow/dfa-problem.pbm` reference. The same churn with rb-cli alone runs on every filesystem that can hold it: `regression-tests/cases/tier3/churn.toml` (it found R-060 .. R-067 and F-012 .. F-018) | -| R6 | 5f1fd54, write-protected media | `hdiutil attach -readonly` raw image, `rb-cli backup /dev/diskN` | **PASS**: logs "is write-protected ... opened read-only", raises no prompt. A card's lock switch is pending hardware | - | -| R11 | f2edc77, cancelled dialog | unit tests on the decoded two-byte reply | a live cancel is pending the user (no dialog was raised unattended) | - | -| R19 | 0093c49, raw-device reads | raw hdiutil device through `rb-cli inspect`: 0 B before, the real size after; unit tests on a device that refuses large reads | the USB floppy drive itself is pending hardware | - | -| Section 3 | 63e8d3f, read-only fallback after a refused read-write escalation | needs a card mounted while Inspect opens it | pending hardware | - | +| R6 | 5f1fd54, write-protected media | `hdiutil attach -readonly` raw image, `rb-cli backup /dev/diskN`; then an SD card held read-only by its lock switch (`Media Read-Only: Yes`, node published `cr--r-----`) through `rb-cli inspect` and `rb-cli restore --device --yes` | **PASS** (hardware, 2026-09-08): the read path logs `/dev/rdisk4 is write-protected; escalating read-only, a restore to it cannot work`, escalates read-only and parses the MBR, raising no second prompt; the write path refuses with `/dev/disk4 is write-protected (media lock switch or read-only image); it cannot be written to` without touching the device. It is the escalation-path message that fires, not `log_read_only_open`'s, because a root-owned node escalates before any direct open can succeed — the doc's quoted wording belongs to the direct-open case | - | +| R11 | f2edc77, cancelled dialog | unit tests on the decoded two-byte reply; then a live cancel against the USB floppy's raw device | **PASS** (hardware, 2026-09-08): one dialog only, `authopen refused /dev/rdisk5: administrator authorization was cancelled`, and no second prompt. The media was writable, so `read_escalation_flags` asked for `O_RDWR` and the fallback arm's `!is_authorization_cancelled` guard is what suppressed the read-only retry — the weaker read-only path would have skipped that guard entirely | - | +| R19 | 0093c49, raw-device reads | raw hdiutil device through `rb-cli inspect`: 0 B before, the real size after; unit tests on a device that refuses large reads; then the USB floppy drive itself | **PASS** (hardware, 2026-09-08): `inspect /dev/rdisk5` reports 1.4 MiB (1474560 bytes), not `0 B`, and detects the HFS superfloppy; `ls` lists the volume's real contents. `read_after_refusal` never fired — this drive serves large reads, which the check allows for | - | +| Section 3 | 63e8d3f, read-only fallback after a refused read-write escalation | a read-write escalation that fails for any non-cancel reason | **PASS** (hardware, 2026-09-08): `authopen did not respond within 120s`, so the guard took the fallback arm — `read-write escalation of /dev/rdisk5 failed (...); retrying read-only` — and the read-only retry succeeded, listing the volume. The trigger was an authopen timeout rather than the mounted-card EBUSY originally predicted; the same guard and the same fallback | - | | Section 5 | build sanity | `cargo test --no-run` with no `target/` at all | 152 s wall, 2.35 GB largest resident set, 62 MB lib-test binary; noted in `docs/build-memory-crashes.md` | - | ## Found during the 2026-09-01 audit, leg 2 (Windows), 2026-09-02 diff --git a/src/cli/verbs/new_partitioned_hd.rs b/src/cli/verbs/new_partitioned_hd.rs index 0b873531..937ef31d 100644 --- a/src/cli/verbs/new_partitioned_hd.rs +++ b/src/cli/verbs/new_partitioned_hd.rs @@ -334,7 +334,8 @@ fn parse_filesystems(specs: &[String], kind: TableKind) -> Result Result<()> { None }; + if device_target_needs_flag(&args.target, args.device) { + bail!( + "{} is a device path, but --device was not given, so it would be written \ + as if it were an image file — skipping the safety preflight, the unmount \ + and the write-protect check. Pass --device --yes to restore to it, or \ + give an image-file path.", + args.target.display() + ); + } if args.device && !args.yes { bail!( "--device target requires --yes (this will overwrite {}).", @@ -348,3 +357,39 @@ fn parse_alignment_mode(s: &str) -> Option { _ => None, } } + +/// Whether a restore target must be refused for want of `--device`. +/// +/// Without the flag `run_restore` takes its image-file arm and create/truncates +/// the target, which on a device node skips the safety preflight, the unmount, +/// the disk claim and the write-protect bail while still writing (R-070). +fn device_target_needs_flag(target: &std::path::Path, device_flag: bool) -> bool { + !device_flag && crate::cli::device_safety::looks_like_device_path(target) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn a_device_target_without_the_flag_is_refused() { + for p in [ + "/dev/disk4", + "/dev/rdisk4", + "/dev/sda", + r"\\.\PhysicalDrive0", + ] { + assert!( + device_target_needs_flag(Path::new(p), false), + "{p} should need --device" + ); + } + } + + #[test] + fn the_flag_and_ordinary_image_paths_are_allowed() { + assert!(!device_target_needs_flag(Path::new("/dev/disk4"), true)); + assert!(!device_target_needs_flag(Path::new("/tmp/out.img"), false)); + } +} diff --git a/src/fs/affs.rs b/src/fs/affs.rs index 6d79268f..9a6f05ce 100644 --- a/src/fs/affs.rs +++ b/src/fs/affs.rs @@ -364,7 +364,10 @@ impl AffsFilesystem { let root_block = locate_root_block(&mut reader, partition_offset, block_size, candidate) .ok_or_else(|| { parse_err(format!( - "no AFFS root block at or below block {candidate}: searched back {MAX_ROOT_SEARCH} blocks. AFFS stores no size, so a partition opened without its true length has to infer the volume's midpoint from the end of the disk; see R-042." + "no AFFS root block at or below block {candidate}: searched back \ + {MAX_ROOT_SEARCH} blocks. AFFS stores no size, so a partition opened \ + without its true length has to infer the volume's midpoint from the end \ + of the disk; see R-042." )) })?; diff --git a/src/fs/ofs_write.rs b/src/fs/ofs_write.rs index 3be7d7a7..a6e64214 100644 --- a/src/fs/ofs_write.rs +++ b/src/fs/ofs_write.rs @@ -573,7 +573,8 @@ pub fn create_blank_ofs(size_bytes: u64, name: &str) -> Result, Filesyst let used = first_dir + 8; if total < used + 8 { return Err(FilesystemError::InvalidData(format!( - "ofs: {size_bytes} bytes is too small for a table of contents, a bitmap, and a root directory (needs at least {} bytes)", + "ofs: {size_bytes} bytes is too small for a table of contents, a bitmap, and a \ + root directory (needs at least {} bytes)", (used + 8) * SECTOR ))); } diff --git a/src/fs/ufs.rs b/src/fs/ufs.rs index 4c45c807..3361b6b4 100644 --- a/src/fs/ufs.rs +++ b/src/fs/ufs.rs @@ -2158,7 +2158,8 @@ impl UfsFilesystem { let max_bytes = (ndaddr + nindir + nindir * nindir) * bs; if data_len > max_bytes { return Err(FilesystemError::Unsupported(format!( - "ufs write_file_data: file size {data_len} > {max_bytes} (triple-indirect writes not implemented)" + "ufs write_file_data: file size {data_len} > {max_bytes} \ + (triple-indirect writes not implemented)" ))); } let cg_hint = (inode.inum / self.ipg) % self.ncg; diff --git a/src/gui/backup_tab.rs b/src/gui/backup_tab.rs index 3da5c7bb..32d2ceb0 100644 --- a/src/gui/backup_tab.rs +++ b/src/gui/backup_tab.rs @@ -2588,11 +2588,6 @@ impl BackupTab { } }; - // TEMP-DIAG: log the access we hold before touching the device. - for line in rusty_backup::os::describe_device_access(&source_path) { - ctx.log.info(line); - } - let partition_filter = if self.selected_partitions.len() < self .source_partitions diff --git a/src/gui/inspect_tab.rs b/src/gui/inspect_tab.rs index 916463d1..9757e62d 100644 --- a/src/gui/inspect_tab.rs +++ b/src/gui/inspect_tab.rs @@ -2882,10 +2882,6 @@ impl InspectTab { .info(format!("Inspecting remote image {}...", path.display())); } else { ctx.log.info(format!("Inspecting {}...", path.display())); - // TEMP-DIAG: log the access we hold before touching the device. - for line in rusty_backup::os::describe_device_access(&path) { - ctx.log.info(line); - } } // Cache CHD path: subsequent per-partition probes (HFS variant probe, diff --git a/src/gui/restore_tab.rs b/src/gui/restore_tab.rs index c6f727f2..0a62c517 100644 --- a/src/gui/restore_tab.rs +++ b/src/gui/restore_tab.rs @@ -1847,12 +1847,6 @@ impl RestoreTab { ctx.log .info(format!("Starting restore to {}", target_path.display(),)); - // TEMP-DIAG: log the access we hold before touching the device, so a - // late "Permission denied" can be traced back to the starting state. - for line in rusty_backup::os::describe_device_access(&target_path) { - ctx.log.info(line); - } - rusty_backup::model::worker::spawn_guarded( Arc::clone(&progress_arc), "disk restore", diff --git a/src/model/source_reader.rs b/src/model/source_reader.rs index 75ef5ba7..599c1a55 100644 --- a/src/model/source_reader.rs +++ b/src/model/source_reader.rs @@ -1282,6 +1282,11 @@ pub fn open_peeled_read_with_entry( if is_container_path(path) { return open_read_dispatch(path, password, inside); } + // Checked before the content probes below: their plain opens would fail a + // root-owned device node with a bare EACCES, denying elevation (R19, R-068). + if crate::cli::device_safety::looks_like_device_path(path) { + return open_device_read(path); + } // Encrypted DMG (encrcdsa v2): the `koly`/partition table only appears after // decryption, so peel the encryption here — this is the layer that carries // the password. The decrypted stream is a plaintext disk image that then @@ -1314,11 +1319,6 @@ pub fn open_peeled_read_with_entry( if let Some(image) = try_open_ndif_carrier(path) { return Ok(Box::new(std::io::Cursor::new(image))); } - // A raw device is never a container, cannot answer seek(End) and, on macOS, - // takes only sector-sized reads: a plain BufReader gave inspect 0 bytes (R19). - if crate::cli::device_safety::looks_like_device_path(path) { - return open_device_read(path); - } let file = File::open(path).with_context(|| format!("open {}", path.display()))?; match detect_image_format_with_path(file, Some(path)) { Ok(format) if !matches!(format, ImageFormat::Raw) => { diff --git a/src/os/macos.rs b/src/os/macos.rs index 1e445198..204af895 100644 --- a/src/os/macos.rs +++ b/src/os/macos.rs @@ -933,63 +933,6 @@ fn log_read_only_open(path: &str, file: &File, rw_errno: i32) { } } -/// TEMP-DIAG: report what access we hold on `path` right now, without -/// escalating. Tracking down a macOS restore that fails with EACCES at the very -/// end after an earlier authopen succeeded. Remove with the rest of TEMP-DIAG -/// once that's understood — grep the tag. -pub fn probe_device_access(path: &str) -> Vec { - let raw = raw_device_path(path); - let mut out = Vec::new(); - - out.push(format!( - "[perm] euid={} ({}), can show an auth prompt: {}", - unsafe { libc::geteuid() }, - if running_as_root() { - "root" - } else { - "not elevated" - }, - if session_can_prompt() { "yes" } else { "no" }, - )); - - // Plain open(2) only — probing must never raise a dialog of its own, or it - // would change the very thing it is measuring. - for (label, flags) in [("O_RDONLY", libc::O_RDONLY), ("O_RDWR", libc::O_RDWR)] { - let Ok(c_path) = CString::new(raw.as_str()) else { - continue; - }; - let fd = unsafe { libc::open(c_path.as_ptr(), flags) }; - if fd >= 0 { - unsafe { libc::close(fd) }; - out.push(format!("[perm] {raw}: {label} ok")); - } else { - let err = std::io::Error::last_os_error(); - out.push(format!( - "[perm] {raw}: {label} failed - {} (errno {})", - err, - err.raw_os_error().unwrap_or(0), - )); - } - } - - let cached = ELEVATED_DEVICES - .lock() - .map(|c| { - c.iter() - .filter(|(p, _, _)| p == &raw) - .map(|(_, w, _)| if *w { "read-write" } else { "read-only" }) - .collect::>() - .join(", ") - }) - .unwrap_or_else(|_| "".to_string()); - out.push(format!( - "[perm] {raw}: cached elevated descriptor: {}", - if cached.is_empty() { "none" } else { &cached }, - )); - - out -} - /// Drop cached descriptors for `path` (all of them when `path` is `None`). /// /// A held descriptor keeps the raw device open, which blocks a clean eject, so @@ -1602,11 +1545,14 @@ pub fn open_source_for_reading(path: &Path) -> Result { Ok(s) => s, Err(e) if flags != libc::O_RDONLY && !is_authorization_cancelled(&e) => { log::warn!( - "read-write escalation of {raw_device} failed ({e:#}); retrying read-only" + "read-write escalation of {raw_device} failed ({e:#}); \ + retrying read-only" ); cached_authopen(&raw_device, libc::O_RDONLY).with_context(|| { format!( - "cannot open {raw_device} for reading: authopen was refused read-write and read-only. If a volume on this disk is still mounted, eject it in Finder and retry" + "cannot open {raw_device} for reading: authopen was refused \ + read-write and read-only. If a volume on this disk is still \ + mounted, eject it in Finder and retry" ) })? } diff --git a/src/os/macos_stub.rs b/src/os/macos_stub.rs index a8e76761..d2410cde 100644 --- a/src/os/macos_stub.rs +++ b/src/os/macos_stub.rs @@ -343,20 +343,6 @@ impl std::io::Seek for SharedDevice { /// Nothing is cached without `authopen`, so releasing is a no-op. pub fn release_elevated_devices(_path: Option<&str>) {} -/// TEMP-DIAG counterpart. Reports the plain-`open(2)` outcome; there is no -/// escalation path here to describe. -pub fn probe_device_access(path: &str) -> Vec { - let mut out = vec![format!( - "[perm] euid={} (no authopen on this build)", - unsafe { libc::geteuid() }, - )]; - match std::fs::OpenOptions::new().read(true).open(path) { - Ok(_) => out.push(format!("[perm] {path}: O_RDONLY ok")), - Err(e) => out.push(format!("[perm] {path}: O_RDONLY failed - {e}")), - } - out -} - /// No optical drive to claim without DiskArbitration. pub(crate) fn claim_optical_disc(_device_path: &str) -> Option { None diff --git a/src/os/mod.rs b/src/os/mod.rs index a95d5fad..40baeed2 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -192,24 +192,6 @@ impl Seek for SourceHandle { } } -/// TEMP-DIAG: describe the access we currently hold on `path`, for the GUI log. -/// -/// Non-escalating and read-only in effect, so it is safe to call before any -/// operation. Added to diagnose a macOS restore failing with `Permission -/// denied` at the very end of an otherwise successful run; delete this and its -/// call sites (grep `TEMP-DIAG`) once that is resolved. -#[allow(unused_variables)] -pub fn describe_device_access(path: &Path) -> Vec { - #[cfg(target_os = "macos")] - { - let s = path.to_string_lossy(); - if s.starts_with("/dev/") { - return macos::probe_device_access(&s); - } - } - Vec::new() -} - /// Release cached privileged device descriptors so the disk can be ejected. /// /// Pass `None` to release every cached device. A no-op off macOS, which has no