From d4312ddf29b9c8fd2bdc502fc2604ce9f097fc48 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 14:47:54 -0400 Subject: [PATCH 1/4] docs: a resume prompt for the four open macOS hardware checks R6, R11, R19 and the read-only escalation fallback have survived two sessions because each needs removable media and a human at the keyboard. The 2026-09-08 attempt got no further: the USB floppy drive dropped off the bus mid-session, so an absent device answered ENXIO and nothing was verified. Collects what the next session needs in one place: that R-068 is the enabler (before it the documented R19 command could not work unprivileged), the exact commands and the verbatim log lines each check must produce, the hardware traps that cost the last attempt (a disappearing device reads as ENXIO, not EIO; an 800K Mac floppy is GCR and unreadable in a PC USB drive whatever size the drive reports), and the eight TEMP-DIAG sites to delete once R6 passes. Co-Authored-By: Claude Opus 5 --- docs/RESUME-macos-hardware-audit.md | 198 ++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/RESUME-macos-hardware-audit.md diff --git a/docs/RESUME-macos-hardware-audit.md b/docs/RESUME-macos-hardware-audit.md new file mode 100644 index 00000000..91044cd5 --- /dev/null +++ b/docs/RESUME-macos-hardware-audit.md @@ -0,0 +1,198 @@ +# RESUME: close the four macOS hardware checks + +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. +- **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. From f1ceb1b2665698c879b937bc600616e52af1493e Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 15:32:38 -0400 Subject: [PATCH 2/4] fix(cli): the raw-device check precedes every content probe (R-069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the same function, inside the default-on `crypto` feature, and opens the source unconditionally — so an unprivileged `rb-cli inspect /dev/rdiskN` still returned a bare EACCES before open_source_for_reading could run, with no dialog and no log output. 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. try_decode_dart escaped the same bug only by accident, reading a metadata length that is 0 for a character device. Found on hardware while running the R-053 floppy check; with this in, the macOS device path reaches DA claim, elevation and the R6 write-protect warning on a physically locked disk for the first time from the CLI. Co-Authored-By: Claude Opus 5 --- docs/Regression_Bugs.md | 39 ++++++++++++++++++++++++++++++++++++++ src/model/source_reader.rs | 10 +++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/Regression_Bugs.md b/docs/Regression_Bugs.md index e0939326..f17272d1 100644 --- a/docs/Regression_Bugs.md +++ b/docs/Regression_Bugs.md @@ -19,6 +19,7 @@ finding depends on a fixture, the fixture is named. | ID | Severity | Area | Finding | |----|----------|------|---------| +| ~~[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 | @@ -298,6 +299,44 @@ 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-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 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) => { From d7af8f414fb901d6566bb1ed02c614df504c00b2 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 16:40:24 -0400 Subject: [PATCH 3/4] fix(cli): a device-shaped restore target requires --device (R-070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four macOS hardware checks and removes the instrumentation that was gated on one of them. R-070: RestoreConfig::target_is_device comes straight from the --device flag, so `rb-cli restore /dev/diskN` without it took run_restore's image-file arm and opened the device with create(true).truncate(true). O_TRUNC is ignored on a device node, so on writable media — or under sudo — that arm writes to the raw device while skipping device_safety::preflight and its system-disk guard, the DiskArbitration unmount, the disk claim, the sector-aligned writer and open_target_for_writing's write-protect bail. Found on hardware: only the card's lock switch stopped the write. A device-shaped target without --device is now refused, naming the flag; auto-enabling device mode was rejected because it turns a typo into a whole-disk overwrite. Covered by device_target_needs_flag's tests. TEMP-DIAG: describe_device_access and its seven call sites are gone. They were kept only until the 2026-08-05 EACCES fix was confirmed on hardware, which is what R6 did today. Verification, all on hardware, 2026-09-08: R6 write-protected media — read path escalates read-only and says why with no second prompt; restore refuses with the write-path message without touching the device (lock-switched SD card) R11 a cancelled dialog — one prompt, no read-only retry, on the O_RDWR path where the !is_authorization_cancelled guard runs R19 raw-device reads — 1474560 bytes and an HFS superfloppy, not 0 B Section 3 read-only fallback after a refused read-write escalation Two log messages had lost their `\` line continuations, embedding 25 spaces of source indentation into user-visible text; both are on the audited path. Co-Authored-By: Claude Opus 5 --- docs/RESUME-audit-3-macos.md | 15 +++++++ docs/RESUME-macos-hardware-audit.md | 19 ++++++++- docs/Regression_Bugs.md | 53 ++++++++++++++++++++---- src/cli/verbs/restore.rs | 45 ++++++++++++++++++++ src/gui/backup_tab.rs | 5 --- src/gui/inspect_tab.rs | 4 -- src/gui/restore_tab.rs | 6 --- src/os/macos.rs | 64 +++-------------------------- src/os/macos_stub.rs | 14 ------- src/os/mod.rs | 18 -------- 10 files changed, 127 insertions(+), 116 deletions(-) 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 index 91044cd5..6ef8da25 100644 --- a/docs/RESUME-macos-hardware-audit.md +++ b/docs/RESUME-macos-hardware-audit.md @@ -1,5 +1,17 @@ # 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 @@ -56,7 +68,12 @@ condition, so note it now. 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. + `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 diff --git a/docs/Regression_Bugs.md b/docs/Regression_Bugs.md index f17272d1..02daa939 100644 --- a/docs/Regression_Bugs.md +++ b/docs/Regression_Bugs.md @@ -19,6 +19,7 @@ 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 | @@ -35,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 | @@ -240,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} @@ -299,6 +302,38 @@ 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 @@ -591,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/restore.rs b/src/cli/verbs/restore.rs index 02aa01b8..554cc853 100644 --- a/src/cli/verbs/restore.rs +++ b/src/cli/verbs/restore.rs @@ -101,6 +101,15 @@ pub fn run(args: RestoreArgs) -> 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/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/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 From 5c061220c07923f3e43382dd114d521624495787 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 17:02:08 -0400 Subject: [PATCH 4/4] fix: restore lost line continuations in four error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rust `\`-newline escape had gone missing from four string literals, so the source indentation became part of the message: 14 to 22 spaces sitting mid-sentence in text the user reads. src/cli/verbs/new_partitioned_hd.rs the HUNK_HEADER rejection src/fs/ufs.rs the triple-indirect size ceiling src/fs/ofs_write.rs the too-small-volume bail src/fs/affs.rs the root-block search failure (three runs in the one string) Each now ends the line with a space then `\`, matching the convention in src/os/macos.rs — the escape strips the newline and all leading whitespace on the next line, so the separating space has to precede the backslash. Same defect as the two repaired on the macOS elevation path in d7af8f41; these were the rest of them. Verified by reading the strings back out of the compiled binary, and with the vintage 1.73 proxy build since three of the four are engine code. Co-Authored-By: Claude Opus 5 --- src/cli/verbs/new_partitioned_hd.rs | 3 ++- src/fs/affs.rs | 5 ++++- src/fs/ofs_write.rs | 3 ++- src/fs/ufs.rs | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) 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 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;