From 349df9a3a40dbcc81b9baca57930d4bbd6e8a522 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 04:27:44 -0300 Subject: [PATCH 01/10] =?UTF-8?q?f3probe:=20port=20the=20device=20backend?= =?UTF-8?q?=20to=20macOS=20(Apple=20Silicon)=20=E2=80=94=20UNVALIDATED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Darwin implementation of the libdevs.c block-device backend so f3probe builds and runs natively on arm64 macOS. libprobe.c (the validated algorithm) is byte-for-byte unchanged; all changes are in the device backend and the build. Key finding that shaped the port: f3probe uses create_block_device(.., RT_NONE) and libprobe.c never calls dev_reset() — so the Linux USBDEVFS_RESET is NOT used by f3probe (it is an f3brew feature, out of scope). f3probe defeats caches with unbuffered raw I/O + overwhelm_cache, so no IOKit/USB reset is needed. Darwin backend (guarded by #ifdef __APPLE__ / #ifdef __linux__): - bdev_open: open /dev/rdiskN + fcntl(F_NOCACHE) instead of O_DIRECT - size: DKIOCGETBLOCKCOUNT x DKIOCGETBLOCKSIZE instead of BLKGETSIZE64/BLKSSZGET - write flush: F_FULLFSYNC + DKIOCSYNCHRONIZECACHE instead of fsync/FADV_DONTNEED - create_block_device: parse/validate the disk path (reject slices), diskutil unmountDisk the whole disk, RT_NONE only - all libudev/USBDEVFS machinery wrapped in #ifdef __linux__ Makefile: drop -ludev on non-Linux; on macOS build only f3probe among the extra tools; resolve argp via `brew --prefix argp-standalone`. Also adds spike.c (Phase-1 cache-defeat de-risking test), PORT-LOG.md (audit trail), and README-macOS.md (build/usage + validation runbook). IMPORTANT: this was authored on a Linux container with no compiler and no hardware. It is NOT compiled and NOT validated against real cards. f3probe is a fraud-detection tool; its verdicts must not be trusted until the Phase 1 spike and Phase 4 correctness battery in PORT-LOG.md / README-macOS.md pass on a Mac. Co-Authored-By: Claude Opus 4.8 --- Makefile | 23 +++- PORT-LOG.md | 193 +++++++++++++++++++++++++++++++ README-macOS.md | 88 ++++++++++++++ spike.c | 300 ++++++++++++++++++++++++++++++++++++++++++++++++ src/libdevs.c | 222 ++++++++++++++++++++++++++++++++++- src/libfile.c | 2 +- 6 files changed, 821 insertions(+), 7 deletions(-) create mode 100644 PORT-LOG.md create mode 100644 README-macOS.md create mode 100644 spike.c diff --git a/Makefile b/Makefile index 829eb5a..b70b13e 100644 --- a/Makefile +++ b/Makefile @@ -19,13 +19,30 @@ ifneq ($(OS), Linux) ARGP = /usr/local ifeq ($(OS), Darwin) ifneq ($(shell command -v brew),) - ARGP = $(shell brew --prefix) + # Apple Silicon Homebrew lives under /opt/homebrew; Intel under + # /usr/local. `brew --prefix` resolves either. Prefer the + # argp-standalone keg path when present (it is more robust if the + # formula is keg-only), else fall back to the Homebrew prefix. + ARGP = $(shell brew --prefix argp-standalone 2>/dev/null || brew --prefix) endif endif CFLAGS += -I$(ARGP)/include LDFLAGS += -L$(ARGP)/lib -largp endif +# libudev is Linux-only; f3probe's Darwin backend does not use it. +ifeq ($(OS), Linux) + UDEV_LIBS = -ludev +else + UDEV_LIBS = +endif + +# Among the "extra" tools, only f3probe is ported to macOS: f3brew needs +# libudev and f3fix needs libparted, both out of scope on Darwin. +ifeq ($(OS), Darwin) + EXTRA_TARGETS = $(BUILD_DIR)/f3probe +endif + all: $(TARGETS) extra: $(EXTRA_TARGETS) @@ -64,10 +81,10 @@ $(BUILD_DIR)/f3read: $(BUILD_DIR)/libutils.o $(BUILD_DIR)/libfile.o $(BUILD_DIR) $(CC) -o $@ $^ $(LDFLAGS) -lm $(BUILD_DIR)/f3probe: $(BUILD_DIR)/libutils.o $(BUILD_DIR)/libflow.o $(BUILD_DIR)/libdevs.o $(BUILD_DIR)/libprobe.o $(BUILD_DIR)/f3probe.o - $(CC) -o $@ $^ $(LDFLAGS) -lm -ludev + $(CC) -o $@ $^ $(LDFLAGS) -lm $(UDEV_LIBS) $(BUILD_DIR)/f3brew: $(BUILD_DIR)/libutils.o $(BUILD_DIR)/libflow.o $(BUILD_DIR)/libdevs.o $(BUILD_DIR)/f3brew.o - $(CC) -o $@ $^ $(LDFLAGS) -lm -ludev + $(CC) -o $@ $^ $(LDFLAGS) -lm $(UDEV_LIBS) $(BUILD_DIR)/f3fix: $(BUILD_DIR)/libutils.o $(BUILD_DIR)/f3fix.o $(CC) -o $@ $^ $(LDFLAGS) -lparted diff --git a/PORT-LOG.md b/PORT-LOG.md new file mode 100644 index 0000000..1e3bd8c --- /dev/null +++ b/PORT-LOG.md @@ -0,0 +1,193 @@ +# PORT-LOG — f3probe → macOS (Apple Silicon) + +> Running audit trail for the port described in `f3probe-macos-port-spec.md`. +> Append-only, table-first. **Read §0 first: it states what environment this +> work was actually done in, and therefore what is and is not proven.** + +--- + +## 0. Environment reality check (READ THIS FIRST) + +The spec assumes "a fresh Claude Code session running on the target Mac" with +physical test cards, card readers, and a Linux reference box. **None of that is +true of the environment this port was authored in.** Established facts: + +| Fact | Evidence | +|---|---| +| Host OS is **Linux aarch64** (a linuxkit container), **not macOS** | `uname -a` → `Linux … 6.12.76-linuxkit … aarch64`; `sw_vers` absent | +| **No C compiler / build tooling** | `cc`, `gcc`, `clang`, `make`, `xcode-select` all absent from `PATH` | +| **No macOS tooling** | `diskutil`, `brew`, `sw_vers` absent | +| **No hardware** | No flash cards, no USB readers, no `/dev/rdiskN` to touch | +| No prior macOS commits | branch `macos-version` == `master` == `origin/master` (commit `67b8a5d`) | + +**Consequence for "success":** the spec defines success *only* as the ported +binary's verdict matching ground truth across the Phase 4 validation battery on +real cards. **That cannot be performed here** — there is no compiler to build it, +no macOS to run it on, and no cards to probe. Per the spec's non-negotiable +principle, I will **not** claim validation I did not do. + +What this environment *can* produce, correctly and reviewably, is the +**environment-independent engineering**: read the seam, design the mapping, +write the Darwin device backend + the Phase-1 spike + the build changes as +source, and hand over a precise runbook the user executes on the Mac. That is +what this log covers. Everything below is **UNVALIDATED CODE** until Phase 1/4/5 +are run on the Mac by the user. + +Phase status in this environment: + +| Phase | What it needs | Status here | +|---|---|---| +| 0 Ground truth | Mac + cards + Linux box | ⛔ Cannot run (no hardware) — runbook provided | +| 1 Cache-defeat spike | compiler + Mac + fake card | 🟡 `spike.c` **written**, **not built/run** — runbook provided | +| 2 Map the seam | source only | ✅ Done (this log, §2) | +| 3 Implement Darwin backend | compiler to verify | 🟡 Code **written** (`libdevs.c`, `Makefile`), **not compiled** | +| 4 Correctness battery | Mac + cards + readers | ⛔ Cannot run — runbook provided | +| 5 Robustness | Mac + cards | ⛔ Cannot run — runbook provided | +| 6 Package fork | — | 🟡 README/runbook written | + +--- + +## 1. The most important finding (corrects a central premise of the spec) + +The spec says (§2.2, §3): *"a USB-level device reset (`USBDEVFS_RESET`) between +the write and verify phases … THAT USB reset is the crux of the whole port."* + +**For `f3probe`, this is not accurate.** Reading the actual source: + +- `f3probe.c:413` constructs the device with `create_block_device(filename, RT_NONE)`. +- `f3probe` exposes **no `--reset-type` option** at all (`f3probe.c:29-62`). +- `RT_NONE` → `bdev_none_reset()` → **does nothing** (`libdevs.c:818-822`). +- **`libprobe.c` contains zero references to reset** (`grep -ni reset libprobe.c` + → nothing). The probe algorithm never resets the device. +- The USB reset path (`bdev_usb_reset`/`bdev_manual_usb_reset`, `USBDEVFS_RESET`) + is used **only by `f3brew`** (`f3brew.c:562` default `RT_MANUAL_USB`, + `f3brew.c:614` `dev_reset`). **`f3brew` is explicitly out of scope.** + +How `f3probe` actually defeats caches (no hardware reset involved): + +1. **OS page cache** — `bdev_open()` uses `O_DIRECT` on Linux (`libdevs.c:474`). +2. **Post-write flush** — `bdev_write_blocks()` does `fsync()` + + `posix_fadvise(POSIX_FADV_DONTNEED)` (`libdevs.c:466-469`). +3. **Controller/card cache** — the algorithm *measures* the cache size + (`find_cache_size`) and then **`overwhelm_cache()`** (`libprobe.c:135-143`) + writes that many sequential blocks to flush stale data out, and issues only + **random reads** to dodge sequential-read caches (`libprobe.c:29-41`). + +**Why this matters:** the hardest, riskiest item the spec front-loads (IOKit USB +reset) is **not required for `f3probe`**. The port reduces to mapping three +primitives — unbuffered open, device-size query, and post-write flush — plus +dropping `libudev`. This is a much smaller and safer surface than the spec +feared. The cache-defeat correctness still must be proven empirically (Phase 1 +spike + Phase 4), because `overwhelm_cache` depends on correctly *measuring* the +reader/card cache, and a lying cache could still defeat it — so the validation +battery remains essential. But the *mechanism* to port is simpler. + +--- + +## 2. The seam — function-by-function mapping (Phase 2) + +Only `src/libdevs.c` is ported. `libprobe.c` is **untouched** (validated algorithm). +The platform-independent layers (`file_device`, `perf_device`, `safe_device`) are +untouched except they already use `aligned_alloc` (C11, present on macOS 10.15+). + +Backend contract used by `f3probe` (via `struct device` vtable, `libdevs.h`): + +| Backend op | Linux primitive (today) | Darwin replacement | Where | +|---|---|---|---| +| open (unbuffered) | `open(O_RDWR \| O_DIRECT)` | open **`/dev/rdiskN`** `O_RDWR` + `fcntl(F_NOCACHE,1)` | `bdev_open` | +| device size | `ioctl(BLKGETSIZE64)` | `ioctl(DKIOCGETBLOCKCOUNT)` × `ioctl(DKIOCGETBLOCKSIZE)` | `create_block_device` | +| logical block size | `ioctl(BLKSSZGET)` | `ioctl(DKIOCGETBLOCKSIZE)` | `create_block_device` | +| read blocks | `lseek`+`read` | identical (raw fd) | `bdev_read_blocks` (unchanged) | +| write blocks + flush | `lseek`+`write`+`fsync`+`fadvise(DONTNEED)` | `lseek`+`write`+`fcntl(F_FULLFSYNC)`+`ioctl(DKIOCSYNCHRONIZECACHE)` | `bdev_write_blocks` | +| reset | `bdev_none_reset` (no-op for `f3probe`) | identical no-op | `bdev_none_reset` (unchanged) | +| free / filename | `close`/`free` | identical | unchanged | +| device enumeration | `libudev` (whole-disk check, USB-backed check) | **dropped**: require path arg; reject slices by name; auto-`diskutil unmountDisk` | `create_block_device` (Darwin) | + +**Structure decision:** `#ifdef` branches **inside `libdevs.c`** (not a separate +`libdevs_darwin.c`). Reason: the `struct device` dispatch and the file/perf/safe +devices live in `libdevs.c` and are shared; only the includes, `bdev_open`, the +size query, the write-flush tail, and `create_block_device` differ. All Linux +`udev`/`USBDEVFS` code is wrapped in `#ifdef __linux__`; a self-contained Darwin +`create_block_device` is added under `#if defined(__APPLE__) && defined(__MACH__)`. +`libprobe.c` stays byte-for-byte identical. + +**Alignment note (validation risk):** raw-device I/O on macOS requires +offset/length to be block-size multiples — satisfied (`pos << block_order`). The +spec recommends *page*-aligned buffers; `f3` aligns buffers to *block* size only +(`align_mem` in `libprobe.c`, `aligned_alloc` in `libflow.c`). On `/dev/rdiskN` +block alignment is normally sufficient, but **if raw reads/writes return EINVAL** +this is the first thing to check (see §5 of the spec). Fixing it would require +touching `libflow.c`'s `dbuf` allocation and `libprobe.c`'s stack buffers — flag +to the user rather than silently changing the forbidden file. + +--- +## 3. Implementation (Phase 3) — written, NOT compiled + +All changes are isolated to the device backend and the build. `libprobe.c` is +**byte-for-byte unchanged** (`git diff --stat` shows it untouched). + +| File | Change | +|---|---| +| `src/libdevs.c` | `_DARWIN_C_SOURCE`; guard ``/`` behind `__linux__`, add `` for Darwin; `bdev_open` uses raw node + `F_NOCACHE` instead of `O_DIRECT`; `bdev_write_blocks` uses `F_FULLFSYNC` + `DKIOCSYNCHRONIZECACHE`; all udev/USBDEVFS code wrapped in `#ifdef __linux__`; a self-contained Darwin `create_block_device()` (path parsing → reject slices, `diskutil unmountDisk`, `DKIOCGETBLOCKCOUNT`×`DKIOCGETBLOCKSIZE`, `RT_NONE` only). | +| `Makefile` | drop `-ludev` on non-Linux (`UDEV_LIBS`); on Darwin `EXTRA_TARGETS` = just `f3probe`; argp path prefers `brew --prefix argp-standalone`. | +| `spike.c` (new) | standalone Phase-1 cache-defeat spike (see §4). | + +**Static review done (no compiler available):** preprocessor guards balanced; +no Linux-only symbol (`O_DIRECT`, `udev_*`, `USBDEVFS_RESET`, `BLKGETSIZE64`, +`BLKSSZGET`, `__progname`) appears outside an `#ifdef __linux__`; goto-cleanup +ladder in the Darwin `create_block_device` is sound; all referenced symbols +(`F_NOCACHE`, `F_FULLFSYNC`, `DKIOC*`, `getprogname`, `system`) are reachable +via the included headers under `_DARWIN_C_SOURCE`. **This is NOT a substitute +for compiling.** Checkpoint 3A (clean compile on arm64) is **NOT met** here — +it must be done on the Mac. + +## 4. What the user must run on the Mac (Phases 0,1,3A,4,5 — the real success bar) + +This is the runbook. **None of it has been executed here.** Until Phase 1 and +Phase 4 pass, the binary's verdicts must NOT be trusted (spec §0). + +**Build (Checkpoint 3A):** +``` +brew install argp-standalone +make extra # on macOS this now builds ONLY build/f3probe +cc -O2 -Wall -Wextra -o spike spike.c +``` +If `make` errors, paste the errors back — this is exactly the verification this +environment could not do. Watch for: missing `argp.h` (fix the brew prefix), +raw-I/O `EINVAL` (the buffer page-alignment caveat in §2), or a `DKIOC` symbol +not found (check `` on the installed SDK). + +**Phase 0 — ground truth (answer key BEFORE trusting the port):** for each card, +record announced size, and the verdict from BOTH (a) macOS `f3write`+`f3read` +and (b) upstream `f3probe` on the Linux box. They must agree. + +**Phase 1 — cache-defeat spike (⚠️ most important):** +``` +sudo ./spike disk4 # dry run: prints the plan + size, writes nothing +sudo ./spike --destroy disk4 # genuine card: VERDICT should say NO ALIASING +sudo ./spike --destroy disk9 # known-fake card: VERDICT should say ALIASING +``` +Then do the printed physical cross-check (eject/reseat + `dd` block 0). The +in-process VERDICT must match the post-reseat `dd` read. If it doesn't, a cache +lied on this reader → STOP, try another reader, do not ship. + +**Phase 4 — correctness battery:** for every (card × reader), run +`sudo build/f3probe /dev/disk4` and confirm `fake_type` + real size match BOTH +references from Phase 0. Include the reseat/`dd` boundary check and the +double-blind A/B test. Record every run. + +**Phase 5 — robustness:** determinism (probe twice), mounted-card handling, +no-sudo error message, Ctrl-C cleanliness, card-still-usable-after, 512B vs 4K +logical, 2 TB timing sanity. + +See `README-macOS.md` for the same steps in fork-README form, plus the results +table to fill in and the honest "validated / not validated" statement. + +## 5. Honest status summary + +- ✅ Done here: seam analysis, the RT_NONE/no-reset correction, the Darwin code, + build changes, the spike, docs. +- ⛔ Not done here (impossible without a Mac/compiler/cards): compile, Phase 1 + spike run, Phase 4 correctness, Phase 5 robustness. +- **Therefore this port is UNVALIDATED.** It is a credible, reviewed starting + point — not a trustworthy fraud-detection binary until the runbook passes. diff --git a/README-macOS.md b/README-macOS.md new file mode 100644 index 0000000..61ff228 --- /dev/null +++ b/README-macOS.md @@ -0,0 +1,88 @@ +# f3probe on macOS (Apple Silicon) — fork notes + +This fork adds a **Darwin device backend** so `f3probe` builds and runs natively +on macOS / Apple Silicon. Upstream marks `f3probe`/`f3brew`/`f3fix` as +Linux-only because the device backend used Linux-kernel interfaces +(`O_DIRECT`, `BLK*` ioctls, `USBDEVFS_RESET`, `libudev`). Only **`f3probe`** is +ported here; `f3brew` and `f3fix` remain Linux-only (out of scope). + +> ⚠️ **Validation status: UNVALIDATED as shipped.** The port was authored on a +> Linux container with no compiler and no hardware. It has **not** been +> compiled, and **not** been run against real cards. `f3probe` is a +> fraud-detection tool — a binary that confidently misreports a fake card is +> worse than no tool. **Do not trust its verdicts until you complete the +> validation below and fill in the results table.** See `PORT-LOG.md` for the +> full chain of reasoning. + +## What changed (and what didn't) + +- **Unchanged:** `src/libprobe.c` — the validated probing algorithm. Not touched. +- **Ported:** `src/libdevs.c` — only the block-device backend, behind + `#ifdef __APPLE__` / `#ifdef __linux__`: + | Purpose | Linux | macOS (this fork) | + |---|---|---| + | unbuffered open | `O_DIRECT` | open `/dev/rdiskN` + `fcntl(F_NOCACHE,1)` | + | device size | `BLKGETSIZE64` | `DKIOCGETBLOCKCOUNT` × `DKIOCGETBLOCKSIZE` | + | post-write flush | `fsync`+`FADV_DONTNEED` | `F_FULLFSYNC` + `DKIOCSYNCHRONIZECACHE` | + | enumeration | `libudev` | drop it; pass the device path; auto `diskutil unmountDisk` | +- **Build:** `Makefile` drops `-ludev` on non-Linux and, on macOS, builds only + `f3probe` among the extra tools. + +**Note on the USB reset:** `f3probe` constructs its device with `RT_NONE` and +its algorithm never calls reset — it defeats caches with unbuffered raw I/O plus +its own `overwhelm_cache` step, not with a hardware reset. So the IOKit/USB +reset that the porting notes feared is **not needed for `f3probe`** (it is an +`f3brew` feature). This is why the port is small. + +## Build (Apple Silicon) + +```sh +brew install argp-standalone # provides argp.h / libargp.a +make extra # builds build/f3probe (macOS: f3probe only) +make all # f3write / f3read (already supported upstream) +``` +Homebrew on Apple Silicon lives under `/opt/homebrew`; the Makefile resolves it +via `brew --prefix argp-standalone`. If linking fails, confirm the static lib +name and path: `ls "$(brew --prefix argp-standalone)/lib"`. + +## Usage & safety + +```sh +sudo build/f3probe /dev/disk4 # pass the WHOLE disk, not a slice (diskNsM) +``` +- Raw disk access needs `sudo`. Without it you get a clear "no access" message. +- The tool opens the **raw** node `/dev/rdisk4` and `diskutil unmountDisk`s the + whole disk first. **Double-check the device** with `diskutil list` — writing + to the wrong `/dev/diskN` destroys data. +- Probing writes only within `(1 MB, announced_end)` and (by default, non + `--destructive`) restores the blocks it touched. + +## You MUST validate before trusting it + +`f3probe` builds cleanly ≠ `f3probe` is correct. Run, in order: + +1. **Ground truth** — for each card, get an independent verdict from macOS + `f3write`+`f3read` and from upstream `f3probe` on a Linux box. They must agree. +2. **Cache-defeat spike** — `cc -O2 -Wall -Wextra -o spike spike.c`, then + `sudo ./spike --destroy diskN` on a known-genuine and a known-fake card, and + do the printed eject/reseat + `dd` cross-check. The in-process verdict must + match the physical `dd` read. If not, the reader's cache is lying → use a + different reader or stop. +3. **Correctness battery** — for every (card × reader), compare `f3probe`'s full + verdict (`fake_type`, real size, wraparound, block order) against both + references. Add the double-blind A/B test on two same-announced-size cards. +4. **Robustness** — determinism, mounted-card handling, no-sudo error, Ctrl-C, + card-usable-after, 512B vs 4K logical, 2 TB timing. + +### Results table (fill this in — do not delete the "not validated" note above) + +| Card | Announced | Reader | f3write/f3read | Linux f3probe | macOS f3probe | Match? | +|------|-----------|--------|----------------|---------------|---------------|--------| +| _e.g. genuine 32GB_ | | | | | | | +| _e.g. fake "2TB"_ | | | | | | | + +### Honest limits statement (complete after Phase 4) + +> Validated on cards {…} via readers {…}. Passing these does **not** prove +> general correctness for untested fake archetypes or untested readers. +> Known-unsupported / untrusted: {…}. diff --git a/spike.c b/spike.c new file mode 100644 index 0000000..0b3f4ee --- /dev/null +++ b/spike.c @@ -0,0 +1,300 @@ +/* + * spike.c — Phase 1 cache-defeat spike for the f3probe macOS port. + * + * This is NOT part of f3. It is the throwaway de-risking program the porting + * spec front-loads: before trusting a ported f3probe, prove that on THIS Mac + + * THIS reader + THIS card, raw unbuffered I/O actually reflects what is + * physically on the flash — so that on a fake card we can OBSERVE address + * aliasing, and no cache silently returns matching-but-stale data. + * + * What it does (mirrors the spec's "killer test"): + * 1. unmount the whole disk, open /dev/rdiskN with F_NOCACHE, + * 2. read the device size via DKIOC* ioctls, + * 3. write pattern A to block 0, flush, read block 0 (sanity), + * 4. write pattern B to the LAST announced block, flush, read block 0 again. + * - block 0 still == A -> no aliasing at this offset (genuine, or real size + * larger than the probed offset), + * - block 0 now == B -> the high write landed on block 0: ALIASING -> fake, + * - anything else -> unexpected; investigate. + * It also reads the last block back and reports it. + * + * The decisive cache check is the PHYSICAL cross-check the program prints at the + * end: after it finishes, physically eject + reseat the card and `dd` block 0; + * the bytes dd reads MUST match what this program reported. If they differ, a + * cache lied and the port cannot be trusted on this reader (escalate per spec). + * + * DESTRUCTIVE: it overwrites block 0 (partition-table area) and the last block + * of the target disk. It WILL destroy data on that disk. + * + * Build: cc -O2 -Wall -Wextra -o spike spike.c + * Usage: sudo ./spike disk4 # DRY RUN: print the plan, write nothing + * sudo ./spike --destroy disk4 # actually write (irreversible) + * + * Exit: 0 = completed (read the printed VERDICT); non-zero = setup/IO error. + */ + +#define _DARWIN_C_SOURCE + +#if !(defined(__APPLE__) && defined(__MACH__)) +#error "spike.c is macOS-only; it uses F_NOCACHE/F_FULLFSYNC and the DKIOC* ioctls." +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TAG_A 0xAAAAAAAAAAAAAAAAULL /* written to block 0 */ +#define TAG_B 0xBBBBBBBBBBBBBBBBULL /* written to the last block */ + +/* Fill @buf with repeating 16-byte records of {tag, block_idx} so a read-back + * tells us unambiguously which write produced the bytes we see. */ +static void fill_pattern(unsigned char *buf, size_t len, + uint64_t tag, uint64_t block_idx) +{ + size_t off; + memset(buf, 0, len); + for (off = 0; off + 16 <= len; off += 16) { + memcpy(buf + off, &tag, 8); + memcpy(buf + off + 8, &block_idx, 8); + } +} + +static void read_record(const unsigned char *buf, uint64_t *tag, uint64_t *idx) +{ + memcpy(tag, buf, 8); + memcpy(idx, buf + 8, 8); +} + +/* Derive "/dev/diskN" (whole) and "/dev/rdiskN" (raw) from a user argument. + * Accepts diskN, rdiskN, /dev/diskN, /dev/rdiskN. Rejects slices (diskNsM). */ +static int disk_paths(const char *arg, char *whole, size_t wl, char *raw, size_t rl) +{ + const char *p = arg, *q; + if (!strncmp(p, "/dev/", 5)) + p += 5; + if (p[0] == 'r' && !strncmp(p + 1, "disk", 4)) + p++; + if (strncmp(p, "disk", 4)) { + fprintf(stderr, "`%s' is not a macOS disk (expected e.g. /dev/disk4)\n", arg); + return -1; + } + q = p + 4; + if (*q < '0' || *q > '9') { + fprintf(stderr, "`%s' has no disk number\n", arg); + return -1; + } + while (*q >= '0' && *q <= '9') + q++; + if (*q != '\0') { + fprintf(stderr, "`%s' looks like a partition/slice; use the WHOLE disk " + "(e.g. /dev/disk4, not /dev/disk4s1)\n", arg); + return -1; + } + snprintf(whole, wl, "/dev/%s", p); + snprintf(raw, rl, "/dev/r%s", p); + return 0; +} + +static int flush_dev(int fd) +{ + if (fcntl(fd, F_FULLFSYNC) < 0) { + perror("fcntl(F_FULLFSYNC)"); + return -1; + } +#ifdef DKIOCSYNCHRONIZECACHE + if (ioctl(fd, DKIOCSYNCHRONIZECACHE) < 0) { + perror("ioctl(DKIOCSYNCHRONIZECACHE)"); + return -1; + } +#endif + return 0; +} + +/* Seek + full write of exactly @len bytes at block @idx. */ +static int write_block(int fd, uint64_t idx, const unsigned char *buf, uint32_t bsz) +{ + off_t want = (off_t)idx * bsz, got = lseek(fd, want, SEEK_SET); + size_t done = 0; + if (got != want) { perror("lseek(write)"); return -1; } + while (done < bsz) { + ssize_t rc = write(fd, buf + done, bsz - done); + if (rc < 0) { perror("write"); return -1; } + done += (size_t)rc; + } + return 0; +} + +static int read_block(int fd, uint64_t idx, unsigned char *buf, uint32_t bsz) +{ + off_t want = (off_t)idx * bsz, got = lseek(fd, want, SEEK_SET); + size_t done = 0; + if (got != want) { perror("lseek(read)"); return -1; } + while (done < bsz) { + ssize_t rc = read(fd, buf + done, bsz - done); + if (rc < 0) { perror("read"); return -1; } + if (rc == 0) { fprintf(stderr, "unexpected EOF reading block %llu\n", + (unsigned long long)idx); return -1; } + done += (size_t)rc; + } + return 0; +} + +static const char *tag_name(uint64_t tag) +{ + if (tag == TAG_A) return "A (block 0)"; + if (tag == TAG_B) return "B (last block)"; + return "??? (neither A nor B)"; +} + +int main(int argc, char **argv) +{ + const char *arg = NULL; + bool destroy = false; + char whole[64], raw[64], cmd[160]; + int fd, i; + uint32_t bsz = 0; + uint64_t bcount = 0, total, last_block; + unsigned char *bufA, *bufB, *rb; + uint64_t tag, idx; + + for (i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--destroy")) destroy = true; + else if (argv[i][0] == '-') { + fprintf(stderr, "unknown option `%s'\n", argv[i]); + return 2; + } else arg = argv[i]; + } + if (!arg) { + fprintf(stderr, + "usage: sudo %s [--destroy] diskN\n" + " (without --destroy this is a DRY RUN: it prints the plan only)\n", + argv[0]); + return 2; + } + if (disk_paths(arg, whole, sizeof whole, raw, sizeof raw)) + return 2; + + printf("Target whole disk : %s\n", whole); + printf("Target raw node : %s\n", raw); + + /* Read geometry first (non-destructive) so the dry run can show real sizes. */ + if (!destroy) { + printf("\n*** DRY RUN — no writes performed. ***\n" + "This program will, with --destroy:\n" + " 1. diskutil unmountDisk %s\n" + " 2. open %s with F_NOCACHE\n" + " 3. write pattern A to block 0, flush, read block 0 back\n" + " 4. write pattern B to the LAST block, flush, read block 0 again\n" + "It OVERWRITES block 0 and the last block (DESTRUCTIVE).\n" + "Re-run as: sudo %s --destroy %s\n", whole, raw, argv[0], arg); + /* Still try to open read-only to report the size, best-effort. */ + fd = open(raw, O_RDONLY); + if (fd >= 0) { + if (!ioctl(fd, DKIOCGETBLOCKCOUNT, &bcount) && + !ioctl(fd, DKIOCGETBLOCKSIZE, &bsz) && bsz) + printf("\nGeometry: %llu blocks x %u bytes = %llu bytes (%.2f GB announced)\n", + (unsigned long long)bcount, bsz, + (unsigned long long)(bcount * (uint64_t)bsz), + (bcount * (double)bsz) / 1e9); + close(fd); + } else { + printf("\n(Could not open %s read-only to read its size: %s.\n" + " You likely need sudo; that is expected.)\n", raw, strerror(errno)); + } + return 0; + } + + snprintf(cmd, sizeof cmd, "diskutil unmountDisk %s", whole); + printf("\nRunning: %s\n", cmd); + if (system(cmd) != 0) + fprintf(stderr, "warning: `%s' did not return 0; open may fail (EBUSY)\n", cmd); + + fd = open(raw, O_RDWR); + if (fd < 0) { fprintf(stderr, "open(%s): %s\n", raw, strerror(errno)); return 1; } + if (fcntl(fd, F_NOCACHE, 1) < 0) { perror("fcntl(F_NOCACHE)"); close(fd); return 1; } + + if (ioctl(fd, DKIOCGETBLOCKCOUNT, &bcount) < 0) { perror("DKIOCGETBLOCKCOUNT"); close(fd); return 1; } + if (ioctl(fd, DKIOCGETBLOCKSIZE, &bsz) < 0) { perror("DKIOCGETBLOCKSIZE"); close(fd); return 1; } + if (!bsz || bcount < 2) { fprintf(stderr, "device too small or bad block size\n"); close(fd); return 1; } + total = bcount * (uint64_t)bsz; + last_block = bcount - 1; + printf("Geometry: %llu blocks x %u bytes = %llu bytes (%.2f GB announced)\n", + (unsigned long long)bcount, bsz, (unsigned long long)total, total / 1e9); + printf("Block 0 offset = 0; last block #%llu offset = %llu bytes\n", + (unsigned long long)last_block, (unsigned long long)(last_block * (uint64_t)bsz)); + + /* Page-aligned buffers (spec belt-and-suspenders for raw I/O). */ + if (posix_memalign((void **)&bufA, (size_t)getpagesize(), bsz) || + posix_memalign((void **)&bufB, (size_t)getpagesize(), bsz) || + posix_memalign((void **)&rb, (size_t)getpagesize(), bsz)) { + fprintf(stderr, "posix_memalign failed\n"); close(fd); return 1; + } + fill_pattern(bufA, bsz, TAG_A, 0); + fill_pattern(bufB, bsz, TAG_B, last_block); + + /* Step 3: A -> block 0, flush, read back (must see A). */ + printf("\n[3] Writing pattern A to block 0 ...\n"); + if (write_block(fd, 0, bufA, bsz) || flush_dev(fd) || read_block(fd, 0, rb, bsz)) goto io_err; + read_record(rb, &tag, &idx); + if (tag != TAG_A) { + printf("VERDICT: SETUP FAILURE — wrote A to block 0 but read back %s.\n" + "The basic write/read path is not working; do not trust further results.\n", + tag_name(tag)); + goto done_fail; + } + printf(" OK: block 0 reads back as pattern A.\n"); + + /* Step 4: B -> last block, flush, re-read block 0. */ + printf("[4] Writing pattern B to last block #%llu ...\n", (unsigned long long)last_block); + if (write_block(fd, last_block, bufB, bsz) || flush_dev(fd)) goto io_err; + if (read_block(fd, 0, rb, bsz)) goto io_err; + read_record(rb, &tag, &idx); + printf(" Block 0 now holds: tag=%s idx=%llu\n", tag_name(tag), (unsigned long long)idx); + + printf("\n================= VERDICT =================\n"); + if (tag == TAG_A) { + printf("NO ALIASING at the last block: block 0 is untouched.\n" + "Consistent with a GENUINE device (or a fake whose real size exceeds\n" + "the full announced size — f3probe's binary search resolves that).\n"); + } else if (tag == TAG_B) { + printf("ALIASING DETECTED: writing the LAST block overwrote block 0.\n" + "This is the wraparound signature of a FAKE device, and the\n" + "in-process unbuffered read SAW it — caches did not hide it.\n"); + } else { + printf("UNEXPECTED: block 0 holds neither A nor B. Investigate (partial\n" + "aliasing, a lying cache, or a flaky reader).\n"); + } + printf("==========================================\n"); + + /* Read the last block back too, for the record. */ + if (!read_block(fd, last_block, rb, bsz)) { + read_record(rb, &tag, &idx); + printf("(For reference, last block #%llu reads back as: tag=%s idx=%llu)\n", + (unsigned long long)last_block, tag_name(tag), (unsigned long long)idx); + } + + close(fd); + printf("\n*** PHYSICAL CROSS-CHECK (do this to be sure no cache lied) ***\n" + "1. Physically EJECT and RE-SEAT the card now:\n" + " diskutil eject %s # then unplug, replug\n" + "2. Re-read block 0 straight off the media and dump the first record:\n" + " sudo dd if=%s bs=%u count=1 2>/dev/null | xxd | head -1\n" + " The first 8 bytes are the tag; 0xAA.. = A (block 0), 0xBB.. = B (last\n" + " block). It MUST match the VERDICT above. If it differs, a cache lied\n" + " on this reader and the port cannot be trusted here (try another reader\n" + " or escalate per the spec).\n", whole, raw, bsz); + return 0; + +io_err: + fprintf(stderr, "I/O error during the spike; results are inconclusive.\n"); +done_fail: + close(fd); + return 1; +} diff --git a/src/libdevs.c b/src/libdevs.c index 7348d8c..de5652e 100644 --- a/src/libdevs.c +++ b/src/libdevs.c @@ -2,6 +2,16 @@ #define _POSIX_C_SOURCE 200809L #define _FILE_OFFSET_BITS 64 +/* On macOS, defining _POSIX_C_SOURCE hides BSD extensions we need here: + * F_NOCACHE / F_FULLFSYNC (), the DKIOC* ioctls (), + * and getprogname() (). _DARWIN_C_SOURCE re-enables them. + * __APPLE__/__MACH__ are compiler built-ins, so this is safe before any + * header is included. + */ +#if defined(__APPLE__) && defined(__MACH__) +#define _DARWIN_C_SOURCE +#endif + #include #include #include @@ -16,9 +26,14 @@ #include #include #include -#include -#include -#include +#ifdef __linux__ +#include /* BLKGETSIZE64, BLKSSZGET. */ +#include /* USBDEVFS_RESET (f3brew only). */ +#include /* Device enumeration (f3brew/Linux). */ +#endif +#if defined(__APPLE__) && defined(__MACH__) +#include /* DKIOCGETBLOCKCOUNT/SIZE, SYNCHRONIZECACHE. */ +#endif #include "libutils.h" #include "libdevs.h" @@ -463,17 +478,58 @@ static int bdev_write_blocks(struct device *dev, const char *buf, rc = write_all(bdev->fd, buf, length); if (rc) return rc; +#if defined(__APPLE__) && defined(__MACH__) + /* Defeat caches on the write side. The raw node was opened with + * F_NOCACHE (OS buffer cache bypassed). F_FULLFSYNC is stronger than + * fsync(2): it tells the drive to flush its own buffers to the media. + * DKIOCSYNCHRONIZECACHE issues a SYNCHRONIZE CACHE down the stack. + * Together these are the macOS analogue of fsync + FADV_DONTNEED. + */ + if (fcntl(bdev->fd, F_FULLFSYNC) < 0) + return errno; +#ifdef DKIOCSYNCHRONIZECACHE + if (ioctl(bdev->fd, DKIOCSYNCHRONIZECACHE) < 0) + return errno; +#endif + return 0; +#else rc = fsync(bdev->fd); if (rc) return rc; return posix_fadvise(bdev->fd, 0, 0, POSIX_FADV_DONTNEED); +#endif } static inline int bdev_open(const char *filename) { +#ifdef __linux__ + /* O_DIRECT bypasses the OS page cache. */ return open(filename, O_RDWR | O_DIRECT); +#elif defined(__APPLE__) && defined(__MACH__) + /* macOS has no O_DIRECT. The caller passes the *raw* node + * (/dev/rdiskN), which is already unbuffered at the character-device + * layer; F_NOCACHE is belt-and-suspenders so any stray buffered access + * also skips the unified buffer cache. + */ + int fd = open(filename, O_RDWR); + if (fd >= 0 && fcntl(fd, F_NOCACHE, 1) < 0) { + int saved_errno = errno; + assert(!close(fd)); + errno = saved_errno; + return -1; + } + return fd; +#else + return open(filename, O_RDWR); +#endif } +#ifdef __linux__ +/* Everything from here to the matching #endif is the Linux udev / USBDEVFS + * machinery. f3probe always uses RT_NONE (it never resets), so none of this is + * needed by the macOS port; it is retained for Linux and for f3brew's USB + * reset. macOS provides its own create_block_device() further below. + */ static struct udev_device *map_dev_to_usb_dev(struct udev_device *dev) { struct udev_device *usb_dev; @@ -814,6 +870,7 @@ static int bdev_usb_reset(struct device *dev) } return 0; } +#endif /* __linux__ : udev / USBDEVFS machinery */ static int bdev_none_reset(struct device *dev) { @@ -834,6 +891,7 @@ static const char *bdev_get_filename(struct device *dev) return dev_bdev(dev)->filename; } +#ifdef __linux__ /* Linux create_block_device() + its udev disk/partition checks. */ static struct udev_device *map_partition_to_disk(struct udev_device *dev) { struct udev_device *disk_dev; @@ -973,6 +1031,164 @@ struct device *create_block_device(const char *filename, enum reset_type rt) error: return NULL; } +#endif /* __linux__ : Linux create_block_device() */ + +#if defined(__APPLE__) && defined(__MACH__) +/* Derive macOS node paths from a user-supplied device argument. + * + * macOS exposes block storage as /dev/diskN (buffered) and /dev/rdiskN (raw, + * unbuffered). Probing must use the *raw* node; unmounting acts on the + * whole-disk node. This accepts "/dev/diskN", "/dev/rdiskN", "diskN" or + * "rdiskN" and rejects partitions/slices (diskNsM), which must not be probed. + * + * Returns 0 on success and fills @whole ("/dev/diskN") and @raw ("/dev/rdiskN"). + */ +static int darwin_disk_paths(const char *arg, char *whole, size_t wlen, + char *raw, size_t rlen) +{ + const char *p = arg; + const char *q; + + if (!strncmp(p, "/dev/", 5)) + p += 5; + /* Tolerate the raw prefix: "rdiskN" -> "diskN". */ + if (p[0] == 'r' && !strncmp(p + 1, "disk", 4)) + p++; + + if (strncmp(p, "disk", 4)) { + warnx("`%s' is not a macOS disk device; expected something like /dev/disk4", + arg); + return -1; + } + + /* Require "disk" followed by digits and nothing else (reject diskNsM). */ + q = p + 4; + if (*q < '0' || *q > '9') { + warnx("`%s' has no disk number; expected something like /dev/disk4", arg); + return -1; + } + while (*q >= '0' && *q <= '9') + q++; + if (*q != '\0') { + warnx("`%s' looks like a partition/slice. Probe the WHOLE disk instead,\n" + "e.g. `/dev/disk4', not `/dev/disk4s1'. Use `diskutil list' to confirm.", + arg); + return -1; + } + + if ((size_t)snprintf(whole, wlen, "/dev/%s", p) >= wlen || + (size_t)snprintf(raw, rlen, "/dev/r%s", p) >= rlen) { + warnx("Device path `%s' is too long", arg); + return -1; + } + return 0; +} + +struct device *create_block_device(const char *filename, enum reset_type rt) +{ + struct block_device *bdev; + char whole[64], raw[64], cmd[128]; + uint64_t block_count = 0; + uint32_t block_size = 0; + int block_order, status; + + if (rt != RT_NONE) { + /* The USB-reset strategies depend on libudev/USBDEVFS and are + * only used by f3brew, which is out of scope on macOS. f3probe + * always passes RT_NONE. + */ + warnx("Device reset is not supported on macOS (only f3probe, which " + "never resets, is supported here; f3brew is out of scope)."); + return NULL; + } + + if (darwin_disk_paths(filename, whole, sizeof(whole), raw, sizeof(raw))) + goto error; + + bdev = malloc(sizeof(*bdev)); + if (!bdev) + goto error; + + bdev->filename = strdup(raw); + if (!bdev->filename) + goto bdev; + + /* macOS auto-mounts removable media; the raw node can't be opened for + * writing while a volume of the disk is mounted. Unmount the WHOLE disk + * (not a single volume) first. diskutil exits 0 if nothing was mounted. + */ + if ((size_t)snprintf(cmd, sizeof(cmd), "diskutil unmountDisk %s", whole) + >= sizeof(cmd)) { + warnx("Internal error: unmount command truncated"); + goto filename; + } + status = system(cmd); + if (status != 0) + warnx("`%s' exited with status %d; if a volume is still mounted the " + "open below will fail with EBUSY.", cmd, status); + + bdev->fd = bdev_open(bdev->filename); + if (bdev->fd < 0) { + if (errno == EACCES && getuid()) { + fprintf(stderr, + "Your user doesn't have access to device `%s'.\n" + "Try to run this program as root:\n" + " sudo %s %s\n" + "In case you don't have access to root, use f3write/f3read.\n", + bdev->filename, getprogname(), filename); + } else if (errno == EBUSY) { + fprintf(stderr, + "Device `%s' is busy (a volume is probably still mounted).\n" + "Unmount the whole disk and retry:\n" + " diskutil unmountDisk %s\n", + bdev->filename, whole); + } else { + err(errno, "Can't open device `%s'", bdev->filename); + } + goto filename; + } + + /* Total size = block count * logical block size. + * (Linux uses BLKGETSIZE64 + BLKSSZGET; this is the Darwin analogue.) + */ + if (ioctl(bdev->fd, DKIOCGETBLOCKCOUNT, &block_count) < 0) { + warn("ioctl(DKIOCGETBLOCKCOUNT) failed on `%s'", bdev->filename); + goto fd; + } + if (ioctl(bdev->fd, DKIOCGETBLOCKSIZE, &block_size) < 0) { + warn("ioctl(DKIOCGETBLOCKSIZE) failed on `%s'", bdev->filename); + goto fd; + } + if (block_size == 0 || !is_power_of_2(block_size)) { + warnx("Device `%s' reported an invalid block size of %" PRIu32 " bytes", + bdev->filename, block_size); + goto fd; + } + + bdev->dev.size_byte = block_count * (uint64_t)block_size; + block_order = ilog2(block_size); + assert(block_size == (1U << block_order)); + bdev->dev.block_order = block_order; + + /* f3probe never resets; keep the no-op reset for interface parity. */ + bdev->dev.reset = bdev_none_reset; + bdev->dev.read_blocks = bdev_read_blocks; + bdev->dev.write_blocks = bdev_write_blocks; + bdev->dev.free = bdev_free; + bdev->dev.get_filename = bdev_get_filename; + + return &bdev->dev; + +fd: + assert(!close(bdev->fd)); +filename: + free((void *)bdev->filename); +bdev: + free(bdev); +error: + return NULL; +} +#endif /* __APPLE__ && __MACH__ : macOS create_block_device() */ struct perf_device { /* This must be the first field. See dev_pdev() for details. */ diff --git a/src/libfile.c b/src/libfile.c index ee5aca6..8c86da8 100644 --- a/src/libfile.c +++ b/src/libfile.c @@ -218,4 +218,4 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) } } -#endif /* Apple Macintosh */ \ No newline at end of file +#endif /* Apple Macintosh */ From f23d825ba590c1230f2af4146b0daa5052a03d9b Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 07:40:37 +0000 Subject: [PATCH 02/10] spike: add --real-size to aim the aliasing write at the wrap boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike wrote pattern B to the LAST announced block, but on a wraparound fake the last block rarely aliases onto block 0 (aliasing is modulo the real size), and limbo-type fakes don't corrupt low blocks at all — so the test could report "no aliasing" on a genuinely fake card and make the cross-check inconclusive. Add --real-size=BYTES: write B at the first announced block past the real capacity (the wrap boundary), which physical-aliases onto block 0 on a clean wraparound fake. Default unchanged (last block) but now clearly labelled as possibly-not-aliasing. Also clarify the VERDICT and the physical cross-check text (power-cycle the card; node may change on reinsert). Co-Authored-By: Claude Opus 4.8 --- spike.c | 101 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/spike.c b/spike.c index 0b3f4ee..a5439f7 100644 --- a/spike.c +++ b/spike.c @@ -51,7 +51,7 @@ #include #define TAG_A 0xAAAAAAAAAAAAAAAAULL /* written to block 0 */ -#define TAG_B 0xBBBBBBBBBBBBBBBBULL /* written to the last block */ +#define TAG_B 0xBBBBBBBBBBBBBBBBULL /* written to the high block */ /* Fill @buf with repeating 16-byte records of {tag, block_idx} so a read-back * tells us unambiguously which write produced the bytes we see. */ @@ -149,23 +149,33 @@ static int read_block(int fd, uint64_t idx, unsigned char *buf, uint32_t bsz) static const char *tag_name(uint64_t tag) { if (tag == TAG_A) return "A (block 0)"; - if (tag == TAG_B) return "B (last block)"; + if (tag == TAG_B) return "B (high block)"; return "??? (neither A nor B)"; } int main(int argc, char **argv) { const char *arg = NULL; - bool destroy = false; + bool destroy = false, have_real = false; char whole[64], raw[64], cmd[160]; int fd, i; uint32_t bsz = 0; - uint64_t bcount = 0, total, last_block; + uint64_t bcount = 0, total, last_block, high_block, real_size = 0; + const char *high_desc; unsigned char *bufA, *bufB, *rb; uint64_t tag, idx; for (i = 1; i < argc; i++) { if (!strcmp(argv[i], "--destroy")) destroy = true; + else if (!strncmp(argv[i], "--real-size=", 12)) { + char *end; + real_size = strtoull(argv[i] + 12, &end, 0); + if (*end != '\0') { + fprintf(stderr, "bad --real-size value `%s'\n", argv[i] + 12); + return 2; + } + have_real = true; + } else if (argv[i][0] == '-') { fprintf(stderr, "unknown option `%s'\n", argv[i]); return 2; @@ -173,8 +183,13 @@ int main(int argc, char **argv) } if (!arg) { fprintf(stderr, - "usage: sudo %s [--destroy] diskN\n" - " (without --destroy this is a DRY RUN: it prints the plan only)\n", + "usage: sudo %s [--destroy] [--real-size=BYTES] diskN\n" + " (without --destroy this is a DRY RUN: it prints the plan only)\n" + " --real-size=BYTES: announced offset at which to write pattern B.\n" + " For a known WRAPAROUND fake, set this to the card's real\n" + " capacity (from f3write/f3read) so the high write aliases onto\n" + " block 0. Default: the last announced block, which often does\n" + " NOT alias onto block 0. Shell tip: --real-size=$((64*1024**3)).\n", argv[0]); return 2; } @@ -191,8 +206,9 @@ int main(int argc, char **argv) " 1. diskutil unmountDisk %s\n" " 2. open %s with F_NOCACHE\n" " 3. write pattern A to block 0, flush, read block 0 back\n" - " 4. write pattern B to the LAST block, flush, read block 0 again\n" - "It OVERWRITES block 0 and the last block (DESTRUCTIVE).\n" + " 4. write pattern B to the high block (default: last block; or the\n" + " wrap boundary if --real-size=BYTES is given), flush, re-read block 0\n" + "It OVERWRITES block 0 and the high block (DESTRUCTIVE).\n" "Re-run as: sudo %s --destroy %s\n", whole, raw, argv[0], arg); /* Still try to open read-only to report the size, best-effort. */ fd = open(raw, O_RDONLY); @@ -227,8 +243,28 @@ int main(int argc, char **argv) last_block = bcount - 1; printf("Geometry: %llu blocks x %u bytes = %llu bytes (%.2f GB announced)\n", (unsigned long long)bcount, bsz, (unsigned long long)total, total / 1e9); - printf("Block 0 offset = 0; last block #%llu offset = %llu bytes\n", - (unsigned long long)last_block, (unsigned long long)(last_block * (uint64_t)bsz)); + + high_block = last_block; + high_desc = "the LAST announced block"; + if (have_real) { + if (real_size == 0 || real_size >= total) { + fprintf(stderr, "--real-size=%llu must be > 0 and < announced size %llu\n", + (unsigned long long)real_size, (unsigned long long)total); + close(fd); return 2; + } + /* The first announced block at/after the real boundary. On a clean + * wraparound fake this physical-aliases onto block 0. */ + high_block = real_size / bsz; + if (high_block == 0 || high_block >= bcount) { + fprintf(stderr, "computed high block %llu is out of range\n", + (unsigned long long)high_block); + close(fd); return 2; + } + high_desc = "the wrap boundary (first block past real size)"; + } + printf("Block 0 offset = 0; high block #%llu offset = %llu bytes (%s)\n", + (unsigned long long)high_block, + (unsigned long long)(high_block * (uint64_t)bsz), high_desc); /* Page-aligned buffers (spec belt-and-suspenders for raw I/O). */ if (posix_memalign((void **)&bufA, (size_t)getpagesize(), bsz) || @@ -237,7 +273,7 @@ int main(int argc, char **argv) fprintf(stderr, "posix_memalign failed\n"); close(fd); return 1; } fill_pattern(bufA, bsz, TAG_A, 0); - fill_pattern(bufB, bsz, TAG_B, last_block); + fill_pattern(bufB, bsz, TAG_B, high_block); /* Step 3: A -> block 0, flush, read back (must see A). */ printf("\n[3] Writing pattern A to block 0 ...\n"); @@ -251,20 +287,24 @@ int main(int argc, char **argv) } printf(" OK: block 0 reads back as pattern A.\n"); - /* Step 4: B -> last block, flush, re-read block 0. */ - printf("[4] Writing pattern B to last block #%llu ...\n", (unsigned long long)last_block); - if (write_block(fd, last_block, bufB, bsz) || flush_dev(fd)) goto io_err; + /* Step 4: B -> high block, flush, re-read block 0. */ + printf("[4] Writing pattern B to %s (#%llu) ...\n", + high_desc, (unsigned long long)high_block); + if (write_block(fd, high_block, bufB, bsz) || flush_dev(fd)) goto io_err; if (read_block(fd, 0, rb, bsz)) goto io_err; read_record(rb, &tag, &idx); printf(" Block 0 now holds: tag=%s idx=%llu\n", tag_name(tag), (unsigned long long)idx); printf("\n================= VERDICT =================\n"); if (tag == TAG_A) { - printf("NO ALIASING at the last block: block 0 is untouched.\n" - "Consistent with a GENUINE device (or a fake whose real size exceeds\n" - "the full announced size — f3probe's binary search resolves that).\n"); + printf("NO ALIASING onto block 0: the high write did not corrupt block 0.\n" + "This alone does NOT mean the card is genuine: limbo-type fakes don't\n" + "corrupt low blocks, and the wrap boundary may map elsewhere than block 0.\n" + "If this is a known WRAPAROUND fake, re-run with --real-size= so B lands on the wrap boundary.\n" + "The cross-check below still confirms whether the read path is honest.\n"); } else if (tag == TAG_B) { - printf("ALIASING DETECTED: writing the LAST block overwrote block 0.\n" + printf("ALIASING DETECTED: writing the high block overwrote block 0.\n" "This is the wraparound signature of a FAKE device, and the\n" "in-process unbuffered read SAW it — caches did not hide it.\n"); } else { @@ -273,23 +313,26 @@ int main(int argc, char **argv) } printf("==========================================\n"); - /* Read the last block back too, for the record. */ - if (!read_block(fd, last_block, rb, bsz)) { + /* Read the high block back too, for the record. */ + if (!read_block(fd, high_block, rb, bsz)) { read_record(rb, &tag, &idx); - printf("(For reference, last block #%llu reads back as: tag=%s idx=%llu)\n", - (unsigned long long)last_block, tag_name(tag), (unsigned long long)idx); + printf("(For reference, high block #%llu reads back as: tag=%s idx=%llu)\n", + (unsigned long long)high_block, tag_name(tag), (unsigned long long)idx); } close(fd); printf("\n*** PHYSICAL CROSS-CHECK (do this to be sure no cache lied) ***\n" - "1. Physically EJECT and RE-SEAT the card now:\n" - " diskutil eject %s # then unplug, replug\n" - "2. Re-read block 0 straight off the media and dump the first record:\n" + "1. Eject, then PHYSICALLY power-cycle the card (unplug the reader or pull\n" + " the card out and reinsert) so the reader and card caches are wiped:\n" + " diskutil eject %s # then physically unplug & replug\n" + "2. The card may come back as a DIFFERENT node — re-check with: diskutil list\n" + "3. Read block 0 straight off the media and dump the first record\n" + " (use the raw rNODE so the OS cache is bypassed; sudo required):\n" " sudo dd if=%s bs=%u count=1 2>/dev/null | xxd | head -1\n" - " The first 8 bytes are the tag; 0xAA.. = A (block 0), 0xBB.. = B (last\n" - " block). It MUST match the VERDICT above. If it differs, a cache lied\n" - " on this reader and the port cannot be trusted here (try another reader\n" - " or escalate per the spec).\n", whole, raw, bsz); + " First 8 bytes = the tag: aaaaaaaa.. = A (block 0), bbbbbbbb.. = B (high\n" + " block). It MUST match the VERDICT above. If it differs, a cache lied on\n" + " this reader and the port cannot be trusted here (try another reader or\n" + " escalate per the spec).\n", whole, raw, bsz); return 0; io_err: From 21a11524b918bf75592360d659c2646ee18df3cc Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 07:53:09 +0000 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20F=5FFULLFSYNC=20is=20invalid=20on?= =?UTF-8?q?=20a=20raw=20disk=20node=20(ENOTTY)=20=E2=80=94=20use=20DKIOCSY?= =?UTF-8?q?NCHRONIZECACHE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 spike run on macOS showed fcntl(fd, F_FULLFSYNC) failing with ENOTTY ("inappropriate ioctl for device") on /dev/rdiskN: F_FULLFSYNC applies to regular files, not raw device nodes. The write itself succeeded; only the flush aborted. f3probe's bdev_write_blocks would have failed identically. Flush the drive's write cache with DKIOCSYNCHRONIZECACHE instead (the analogue of Linux fsync on a block device). The raw node is opened with F_NOCACHE, so the OS buffer cache is already bypassed. Tolerate ENOTTY/ENOTSUP (readers that don't implement SYNCHRONIZE CACHE) with a one-time warning and surface other errno. Also a positive signal: the raw write to block 0 worked, so the buffer page-alignment concern did not materialise on this reader. Co-Authored-By: Claude Opus 4.8 --- PORT-LOG.md | 7 +++++++ spike.c | 19 +++++++++++-------- src/libdevs.c | 27 ++++++++++++++++++--------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/PORT-LOG.md b/PORT-LOG.md index 1e3bd8c..c109cc4 100644 --- a/PORT-LOG.md +++ b/PORT-LOG.md @@ -191,3 +191,10 @@ table to fill in and the honest "validated / not validated" statement. spike run, Phase 4 correctness, Phase 5 robustness. - **Therefore this port is UNVALIDATED.** It is a credible, reviewed starting point — not a trustworthy fraud-detection binary until the runbook passes. + +## 6. Phase 1 field findings (from on-Mac runs by the user) + +| Date | Finding | Action | +|---|---|---| +| 2026-05-31 | **`fcntl(fd, F_FULLFSYNC)` fails with `ENOTTY` on `/dev/rdiskN`.** F_FULLFSYNC is only valid on regular files; on a raw device node it is "inappropriate ioctl for device". The spike aborted at the first flush; `f3probe`'s `bdev_write_blocks` would have failed identically. | Drop F_FULLFSYNC on the raw node. Use `DKIOCSYNCHRONIZECACHE` only (the drive-cache flush, analogue of Linux fsync-on-block-device). Tolerate `ENOTTY`/`ENOTSUP` (reader without SYNCHRONIZE CACHE) with a one-time warning; surface other errno. Fixed in `spike.c` `flush_dev()` and `libdevs.c` `bdev_write_blocks()`. | +| 2026-05-31 | Raw **write to block 0 succeeded** before the flush error → no buffer-alignment/`EINVAL` problem on this reader (the page-alignment caveat in §2 did not bite). `diskutil unmountDisk` worked. | None; positive signal. | diff --git a/spike.c b/spike.c index a5439f7..bbdc57d 100644 --- a/spike.c +++ b/spike.c @@ -104,15 +104,18 @@ static int disk_paths(const char *arg, char *whole, size_t wl, char *raw, size_t static int flush_dev(int fd) { - if (fcntl(fd, F_FULLFSYNC) < 0) { - perror("fcntl(F_FULLFSYNC)"); - return -1; - } + /* On a RAW disk node, F_FULLFSYNC is not applicable (it returns ENOTTY); + * the drive's write cache is flushed with DKIOCSYNCHRONIZECACHE. This is + * best-effort: if the reader doesn't implement SYNCHRONIZE CACHE we note it + * and continue — the raw write already left the OS, and the physical + * eject/reseat cross-check is the real arbiter of whether a cache lied. */ #ifdef DKIOCSYNCHRONIZECACHE - if (ioctl(fd, DKIOCSYNCHRONIZECACHE) < 0) { - perror("ioctl(DKIOCSYNCHRONIZECACHE)"); - return -1; - } + if (ioctl(fd, DKIOCSYNCHRONIZECACHE) < 0) + fprintf(stderr, " note: DKIOCSYNCHRONIZECACHE failed (%s); continuing\n", + strerror(errno)); +#else + (void)fd; + fprintf(stderr, " note: built without DKIOCSYNCHRONIZECACHE; no cache flush\n"); #endif return 0; } diff --git a/src/libdevs.c b/src/libdevs.c index de5652e..4995237 100644 --- a/src/libdevs.c +++ b/src/libdevs.c @@ -479,17 +479,26 @@ static int bdev_write_blocks(struct device *dev, const char *buf, if (rc) return rc; #if defined(__APPLE__) && defined(__MACH__) - /* Defeat caches on the write side. The raw node was opened with - * F_NOCACHE (OS buffer cache bypassed). F_FULLFSYNC is stronger than - * fsync(2): it tells the drive to flush its own buffers to the media. - * DKIOCSYNCHRONIZECACHE issues a SYNCHRONIZE CACHE down the stack. - * Together these are the macOS analogue of fsync + FADV_DONTNEED. + /* Defeat caches on the write side. The raw node (/dev/rdiskN) was opened + * with F_NOCACHE, so the OS buffer cache is already bypassed. NOTE: + * F_FULLFSYNC is NOT valid on a raw device node — it returns ENOTTY. The + * drive's own write cache is flushed with DKIOCSYNCHRONIZECACHE (issues + * SYNCHRONIZE CACHE), the analogue of Linux's fsync on a block device. If + * the reader doesn't implement SYNCHRONIZE CACHE, warn once and continue + * relying on the raw F_NOCACHE write — such a reader must be validated with + * spike.c's eject/reseat cross-check before its verdicts can be trusted. */ - if (fcntl(bdev->fd, F_FULLFSYNC) < 0) - return errno; #ifdef DKIOCSYNCHRONIZECACHE - if (ioctl(bdev->fd, DKIOCSYNCHRONIZECACHE) < 0) - return errno; + if (ioctl(bdev->fd, DKIOCSYNCHRONIZECACHE) < 0) { + static int warned_no_sync; + if (errno != ENOTTY && errno != ENOTSUP) + return errno; + if (!warned_no_sync) { + warned_no_sync = 1; + warnx("reader does not support DKIOCSYNCHRONIZECACHE; relying on raw " + "F_NOCACHE I/O — validate with the spike eject/reseat cross-check"); + } + } #endif return 0; #else From 4e0f2ce573a2165e7759059e0338bc0c63cacc3b Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 08:07:42 +0000 Subject: [PATCH 04/10] PORT-LOG: record Phase 1 cache-defeat PASS on the limbo card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike (post-flush-fix) on the known-limbo card: block 0 in-process == dd after reseat (A); limbo block #249856 written with B read back as zeros in-process and zeros via dd after power-cycle — the discarded write never returned from cache. No cache lied on this reader. Recipe confirmed: rdisk + F_NOCACHE + DKIOCSYNCHRONIZECACHE, no USB reset. Definitive proof (f3probe + overwhelm_cache matching ground truth) still pending. Co-Authored-By: Claude Opus 4.8 --- PORT-LOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/PORT-LOG.md b/PORT-LOG.md index c109cc4..dd6251a 100644 --- a/PORT-LOG.md +++ b/PORT-LOG.md @@ -198,3 +198,4 @@ table to fill in and the honest "validated / not validated" statement. |---|---|---| | 2026-05-31 | **`fcntl(fd, F_FULLFSYNC)` fails with `ENOTTY` on `/dev/rdiskN`.** F_FULLFSYNC is only valid on regular files; on a raw device node it is "inappropriate ioctl for device". The spike aborted at the first flush; `f3probe`'s `bdev_write_blocks` would have failed identically. | Drop F_FULLFSYNC on the raw node. Use `DKIOCSYNCHRONIZECACHE` only (the drive-cache flush, analogue of Linux fsync-on-block-device). Tolerate `ENOTTY`/`ENOTSUP` (reader without SYNCHRONIZE CACHE) with a one-time warning; surface other errno. Fixed in `spike.c` `flush_dev()` and `libdevs.c` `bdev_write_blocks()`. | | 2026-05-31 | Raw **write to block 0 succeeded** before the flush error → no buffer-alignment/`EINVAL` problem on this reader (the page-alignment caveat in §2 did not bite). `diskutil unmountDisk` worked. | None; positive signal. | +| 2026-05-31 | **Phase 1 cache-defeat PASS on the limbo card.** After the flush fix: block 0 in-process read = `A`, post-reseat `dd` = `A` (match). Wrote `B` to the first limbo block (#249856); in-process read came back as **zeros (not `B`)** and post-reseat `dd` = zeros (match) — the discarded limbo write never returned from cache, in-process or physically. No cache lied on this reader. Ground truth (Linux upstream f3probe, same card): `limbo`, usable 122.00 MB / 249856 blocks, last good block 249855, module 2^41, ~512 MB cache. | Cache-defeat recipe **confirmed**: `/dev/rdiskN` + `F_NOCACHE` + `DKIOCSYNCHRONIZECACHE` (no USB reset). Caveat: single-point test; the definitive device-cache-defeat proof is f3probe (with `overwhelm_cache`) matching ground truth — next. Genuine-card half of Checkpoint 1 still pending. | From 22280b0b51e95646440ffa21e54710e7c7f1c032 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 08:15:11 +0000 Subject: [PATCH 05/10] libdevs(darwin): print offset/errno/alignment on raw I/O failure f3probe reported the limbo card as "damaged / 0 blocks" in 0.5ms: its first write (at the last announced block, offset ~2TB) failed and the non-verbose probe hid the errno behind a silenced callback. Add a Darwin diagnostic on the raw read/write error paths printing offset, block range, errno, and the buffer's alignment vs pagesize and blocksize, to distinguish an alignment bug (EINVAL + unaligned buffer) from the device/USB stack rejecting a high-LBA access (EIO/ENXIO at a huge offset). Co-Authored-By: Claude Opus 4.8 --- src/libdevs.c | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/libdevs.c b/src/libdevs.c index 4995237..297f9cd 100644 --- a/src/libdevs.c +++ b/src/libdevs.c @@ -460,7 +460,20 @@ static int bdev_read_blocks(struct device *dev, char *buf, if (off_ret < 0) return - errno; assert(off_ret == offset); - return read_all(bdev->fd, buf, length); + { + int rc = read_all(bdev->fd, buf, length); +#if defined(__APPLE__) && defined(__MACH__) + if (rc) + warnx("raw read failed: off=%lld len=%zu blocks=[%llu,%llu] " + "errno=%d (%s); buf%%pagesize=%lu buf%%blocksize=%lu", + (long long)offset, length, + (unsigned long long)first_pos, (unsigned long long)last_pos, + -rc, strerror(-rc), + (unsigned long)((uintptr_t)buf % (uintptr_t)getpagesize()), + (unsigned long)((uintptr_t)buf & (dev_get_block_size(dev) - 1))); +#endif + return rc; + } } static int bdev_write_blocks(struct device *dev, const char *buf, @@ -476,8 +489,21 @@ static int bdev_write_blocks(struct device *dev, const char *buf, return - errno; assert(off_ret == offset); rc = write_all(bdev->fd, buf, length); - if (rc) + if (rc) { +#if defined(__APPLE__) && defined(__MACH__) + /* Darwin diagnostic: the non-verbose probe hides I/O errors behind a + * silenced callback, so surface the offset / errno / buffer alignment + * of a raw write failure here. */ + warnx("raw write failed: off=%lld len=%zu blocks=[%llu,%llu] " + "errno=%d (%s); buf%%pagesize=%lu buf%%blocksize=%lu", + (long long)offset, length, + (unsigned long long)first_pos, (unsigned long long)last_pos, + rc, strerror(rc), + (unsigned long)((uintptr_t)buf % (uintptr_t)getpagesize()), + (unsigned long)((uintptr_t)buf & (dev_get_block_size(dev) - 1))); +#endif return rc; + } #if defined(__APPLE__) && defined(__MACH__) /* Defeat caches on the write side. The raw node (/dev/rdiskN) was opened * with F_NOCACHE, so the OS buffer cache is already bypassed. NOTE: From 93bccaf7ab848c04ca95670ecaf504fdc3c98154 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 08:17:31 +0000 Subject: [PATCH 06/10] libdevs(darwin): use sysconf(_SC_PAGESIZE), not getpagesize() getpagesize() is a legacy BSD call hidden when _POSIX_C_SOURCE is defined (libdevs.c sets it), causing -Wimplicit-function-declaration -> error on macOS. sysconf(_SC_PAGESIZE) is core POSIX and always declared. Co-Authored-By: Claude Opus 4.8 --- src/libdevs.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libdevs.c b/src/libdevs.c index 297f9cd..bc748d2 100644 --- a/src/libdevs.c +++ b/src/libdevs.c @@ -469,7 +469,7 @@ static int bdev_read_blocks(struct device *dev, char *buf, (long long)offset, length, (unsigned long long)first_pos, (unsigned long long)last_pos, -rc, strerror(-rc), - (unsigned long)((uintptr_t)buf % (uintptr_t)getpagesize()), + (unsigned long)((uintptr_t)buf % (uintptr_t)sysconf(_SC_PAGESIZE)), (unsigned long)((uintptr_t)buf & (dev_get_block_size(dev) - 1))); #endif return rc; @@ -499,7 +499,7 @@ static int bdev_write_blocks(struct device *dev, const char *buf, (long long)offset, length, (unsigned long long)first_pos, (unsigned long long)last_pos, rc, strerror(rc), - (unsigned long)((uintptr_t)buf % (uintptr_t)getpagesize()), + (unsigned long)((uintptr_t)buf % (uintptr_t)sysconf(_SC_PAGESIZE)), (unsigned long)((uintptr_t)buf & (dev_get_block_size(dev) - 1))); #endif return rc; From cfb9043ac2644d7af7571693e77bebb1c3b98477 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 08:23:51 +0000 Subject: [PATCH 07/10] PORT-LOG: Phase 4 verdict MATCH on the limbo card (+ determinism caveat) macOS f3probe verdict is identical to Linux ground truth: limbo, usable 122 MB / 249856 blocks, last good 249855, module 2^41, announced 1.95 TB. macOS found cache size 0 vs Linux 512 MB (verdict unaffected; consistent with the spike showing no cache lie on this reader). Caveat recorded: the immediately-prior run returned "damaged/0 blocks" from the same I/O code (first write at the ~2TB last block failed twice). Non-determinism to characterize in Phase 5; direction is safe (intermittent damaged, not a false good) but unquantified. Co-Authored-By: Claude Opus 4.8 --- PORT-LOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PORT-LOG.md b/PORT-LOG.md index dd6251a..8ca2e8f 100644 --- a/PORT-LOG.md +++ b/PORT-LOG.md @@ -199,3 +199,6 @@ table to fill in and the honest "validated / not validated" statement. | 2026-05-31 | **`fcntl(fd, F_FULLFSYNC)` fails with `ENOTTY` on `/dev/rdiskN`.** F_FULLFSYNC is only valid on regular files; on a raw device node it is "inappropriate ioctl for device". The spike aborted at the first flush; `f3probe`'s `bdev_write_blocks` would have failed identically. | Drop F_FULLFSYNC on the raw node. Use `DKIOCSYNCHRONIZECACHE` only (the drive-cache flush, analogue of Linux fsync-on-block-device). Tolerate `ENOTTY`/`ENOTSUP` (reader without SYNCHRONIZE CACHE) with a one-time warning; surface other errno. Fixed in `spike.c` `flush_dev()` and `libdevs.c` `bdev_write_blocks()`. | | 2026-05-31 | Raw **write to block 0 succeeded** before the flush error → no buffer-alignment/`EINVAL` problem on this reader (the page-alignment caveat in §2 did not bite). `diskutil unmountDisk` worked. | None; positive signal. | | 2026-05-31 | **Phase 1 cache-defeat PASS on the limbo card.** After the flush fix: block 0 in-process read = `A`, post-reseat `dd` = `A` (match). Wrote `B` to the first limbo block (#249856); in-process read came back as **zeros (not `B`)** and post-reseat `dd` = zeros (match) — the discarded limbo write never returned from cache, in-process or physically. No cache lied on this reader. Ground truth (Linux upstream f3probe, same card): `limbo`, usable 122.00 MB / 249856 blocks, last good block 249855, module 2^41, ~512 MB cache. | Cache-defeat recipe **confirmed**: `/dev/rdiskN` + `F_NOCACHE` + `DKIOCSYNCHRONIZECACHE` (no USB reset). Caveat: single-point test; the definitive device-cache-defeat proof is f3probe (with `overwhelm_cache`) matching ground truth — next. Genuine-card half of Checkpoint 1 still pending. | +| 2026-05-31 | First `f3probe` build error on the diagnostic: `getpagesize()` undeclared under `_POSIX_C_SOURCE`. | Use `sysconf(_SC_PAGESIZE)` (core POSIX, always declared). Fixed. | +| 2026-05-31 | **Phase 4 verdict MATCH (limbo card, reader as used).** `f3probe --destructive --time-ops /dev/disk10` → `limbo`, usable **122.00 MB / 249856 blocks**, last good 249855, module 2^41, announced 1.95 TB — **identical to the Linux ground truth**. Probe time 1'31". Note: macOS reported cache size **0** (vs Linux 512 MB); verdict unaffected — consistent with the spike (no cache lie on this reader), so `overwhelm_cache` wasn't needed and the probe was ~15× faster. | Port correctly classifies this counterfeit on macOS. ✅ (one card, one reader) | +| 2026-05-31 | **Non-determinism observed (must characterize).** The run immediately before the match — *same I/O code* — returned `damaged / 0 blocks` because the first write (last announced block, offset ~2 TB) failed twice, then the probe bailed. The next run's writes succeeded and it completed correctly. Likely a flaky cheap-fake/bridge at the extreme announced LBA (perhaps aggravated by the card settling after the spike's reseats). Direction is the *safe* one (intermittent "damaged", not a false "good"), but unquantified. | **OPEN — Phase 5 determinism:** run `f3probe` ≥5×; require consistent `limbo`/122 MB. Tolerate occasional `damaged` (document rate); a single `good`/larger-size = hard stop, do not ship. | From e11552c1b49c9f6533ac66f0c90ab16e78a3bea7 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Sun, 31 May 2026 08:37:04 +0000 Subject: [PATCH 08/10] docs: Phase 5 determinism PASS; state honest validation scope 5/5 f3probe runs on the limbo card returned identical limbo/122MB; the earlier one-off "damaged" did not recur (safe-direction transient at the extreme LBA). Update the README banner from "UNVALIDATED" to the accurate scope: validated on one limbo card + one reader against Linux ground truth, deterministic, physical read confirmed; NOT proven for genuine cards, other archetypes, other readers, or 4K media. Fill in the results table and honest limits statement. Co-Authored-By: Claude Opus 4.8 --- PORT-LOG.md | 1 + README-macOS.md | 53 +++++++++++++++++++++++++++++++------------------ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/PORT-LOG.md b/PORT-LOG.md index 8ca2e8f..fe84206 100644 --- a/PORT-LOG.md +++ b/PORT-LOG.md @@ -202,3 +202,4 @@ table to fill in and the honest "validated / not validated" statement. | 2026-05-31 | First `f3probe` build error on the diagnostic: `getpagesize()` undeclared under `_POSIX_C_SOURCE`. | Use `sysconf(_SC_PAGESIZE)` (core POSIX, always declared). Fixed. | | 2026-05-31 | **Phase 4 verdict MATCH (limbo card, reader as used).** `f3probe --destructive --time-ops /dev/disk10` → `limbo`, usable **122.00 MB / 249856 blocks**, last good 249855, module 2^41, announced 1.95 TB — **identical to the Linux ground truth**. Probe time 1'31". Note: macOS reported cache size **0** (vs Linux 512 MB); verdict unaffected — consistent with the spike (no cache lie on this reader), so `overwhelm_cache` wasn't needed and the probe was ~15× faster. | Port correctly classifies this counterfeit on macOS. ✅ (one card, one reader) | | 2026-05-31 | **Non-determinism observed (must characterize).** The run immediately before the match — *same I/O code* — returned `damaged / 0 blocks` because the first write (last announced block, offset ~2 TB) failed twice, then the probe bailed. The next run's writes succeeded and it completed correctly. Likely a flaky cheap-fake/bridge at the extreme announced LBA (perhaps aggravated by the card settling after the spike's reseats). Direction is the *safe* one (intermittent "damaged", not a false "good"), but unquantified. | **OPEN — Phase 5 determinism:** run `f3probe` ≥5×; require consistent `limbo`/122 MB. Tolerate occasional `damaged` (document rate); a single `good`/larger-size = hard stop, do not ship. | +| 2026-05-31 | **Phase 5 determinism PASS.** 5 consecutive `f3probe --destructive /dev/disk10` runs all returned identical `limbo` / 122.00 MB / 249856 blocks. The earlier one-off `damaged` did **not** recur. | Determinism confirmed for this card+reader. The transient `damaged` is documented as a known, safe-direction (never a false `good`) intermittency at the extreme announced LBA — not reproduced in 5 runs. | diff --git a/README-macOS.md b/README-macOS.md index 61ff228..4f9d4d0 100644 --- a/README-macOS.md +++ b/README-macOS.md @@ -6,13 +6,16 @@ Linux-only because the device backend used Linux-kernel interfaces (`O_DIRECT`, `BLK*` ioctls, `USBDEVFS_RESET`, `libudev`). Only **`f3probe`** is ported here; `f3brew` and `f3fix` remain Linux-only (out of scope). -> ⚠️ **Validation status: UNVALIDATED as shipped.** The port was authored on a -> Linux container with no compiler and no hardware. It has **not** been -> compiled, and **not** been run against real cards. `f3probe` is a -> fraud-detection tool — a binary that confidently misreports a fake card is -> worse than no tool. **Do not trust its verdicts until you complete the -> validation below and fill in the results table.** See `PORT-LOG.md` for the -> full chain of reasoning. +> ⚠️ **Validation status: validated on ONE card + ONE reader; not yet proven +> beyond that.** The port compiles cleanly on Apple Silicon and, on a known +> **limbo** counterfeit ("1.95 TB" announced / 122 MB real) through one USB +> reader, produces a verdict **identical to Linux upstream f3probe** and stable +> across 5 consecutive runs; the spike's eject/reseat `dd` cross-check confirmed +> it reads physical media, not cache. It has **NOT** been tested on a genuine +> device, on other fake archetypes (wraparound/chain/bad), on other readers, or +> on 4K-logical media. `f3probe` is a fraud-detection tool — treat untested +> configurations as unproven. See the results table and limits below, and +> `PORT-LOG.md` for the full chain of evidence. ## What changed (and what didn't) @@ -74,15 +77,27 @@ sudo build/f3probe /dev/disk4 # pass the WHOLE disk, not a slice (diskN 4. **Robustness** — determinism, mounted-card handling, no-sudo error, Ctrl-C, card-usable-after, 512B vs 4K logical, 2 TB timing. -### Results table (fill this in — do not delete the "not validated" note above) - -| Card | Announced | Reader | f3write/f3read | Linux f3probe | macOS f3probe | Match? | -|------|-----------|--------|----------------|---------------|---------------|--------| -| _e.g. genuine 32GB_ | | | | | | | -| _e.g. fake "2TB"_ | | | | | | | - -### Honest limits statement (complete after Phase 4) - -> Validated on cards {…} via readers {…}. Passing these does **not** prove -> general correctness for untested fake archetypes or untested readers. -> Known-unsupported / untrusted: {…}. +### Results table + +| Card | Announced | Reader | Linux f3probe (ground truth) | macOS f3probe | Match? | +|------|-----------|--------|------------------------------|---------------|--------| +| limbo fake | 1.95 TB (4194304000 × 512) | USB reader (model TBD — fill in) | `limbo`, usable 122.00 MB / 249856 blk, last 249855, module 2^41 | `limbo`, usable 122.00 MB / 249856 blk, last 249855, module 2^41 — **5/5 runs identical** | ✅ | +| _genuine card_ | | | | _not yet tested_ | — | +| _other fake archetypes_ | | | | _not yet tested_ | — | + +Notes: on this card macOS reported cache size 0 (probe ~1.5 min) vs Linux 512 MB +(~23 min); the verdict was unaffected. The spike's eject/reseat `dd` cross-check +confirmed the read path reflects physical media. One earlier run returned a +transient `damaged` (a write error at the extreme announced LBA) that did not +recur in 5 subsequent runs — a safe-direction intermittency (never a false +`good`), documented in `PORT-LOG.md`. + +### Honest limits statement + +> **Validated:** one **limbo** counterfeit ("1.95 TB" announced, 122 MB real) +> via one USB reader, against Linux upstream f3probe — deterministic over 5 runs, +> and confirmed reading physical media (spike cross-check). +> **NOT proven:** genuine devices; other fake archetypes (wraparound, chain, +> bad); other USB readers or the built-in SD slot; 4K-logical-sector devices; +> behaviour under interrupt/again-after-reformat (Phase 5 leftovers). +> Treat all of the above as unsupported until tested. From a8d7e075f2c655a805b89dca52571ed66dbfe791 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Thu, 4 Jun 2026 04:17:38 +0000 Subject: [PATCH 09/10] libdevs(darwin): handle write-protected cards; explain root EACCES Under sudo (uid 0) an EACCES on /dev/rdiskN fell through to a bare "Permission denied": the only EACCES hint was gated behind getuid(), and there was no read-only/write-protect detection at all. A locked SD card (physical lock switch) thus produced a confusing error even though the cause is mundane. - Add darwin_media_is_write_protected() (O_RDONLY open + DKIOCISWRITABLE, #ifdef-guarded, true only when definitely locked) and darwin_warn_write_protected(). - EACCES now branches: write-protected -> lock-switch hint; non-root -> existing run-as-root hint; root -> new message naming write-protect + Full Disk Access (root bypasses classic UNIX DAC, so those are the real causes). Proactive post-open check catches bridges that open RW yet reject writes. The err() path restores the saved open_errno. - Document as a PORT-LOG field finding and a README-macOS troubleshooting note. Not yet built on macOS; verify lock ON -> clear message, lock OFF -> probe proceeds. Co-Authored-By: Claude Opus 4.8 --- PORT-LOG.md | 1 + README-macOS.md | 6 +++++ src/libdevs.c | 69 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/PORT-LOG.md b/PORT-LOG.md index fe84206..22e3002 100644 --- a/PORT-LOG.md +++ b/PORT-LOG.md @@ -203,3 +203,4 @@ table to fill in and the honest "validated / not validated" statement. | 2026-05-31 | **Phase 4 verdict MATCH (limbo card, reader as used).** `f3probe --destructive --time-ops /dev/disk10` → `limbo`, usable **122.00 MB / 249856 blocks**, last good 249855, module 2^41, announced 1.95 TB — **identical to the Linux ground truth**. Probe time 1'31". Note: macOS reported cache size **0** (vs Linux 512 MB); verdict unaffected — consistent with the spike (no cache lie on this reader), so `overwhelm_cache` wasn't needed and the probe was ~15× faster. | Port correctly classifies this counterfeit on macOS. ✅ (one card, one reader) | | 2026-05-31 | **Non-determinism observed (must characterize).** The run immediately before the match — *same I/O code* — returned `damaged / 0 blocks` because the first write (last announced block, offset ~2 TB) failed twice, then the probe bailed. The next run's writes succeeded and it completed correctly. Likely a flaky cheap-fake/bridge at the extreme announced LBA (perhaps aggravated by the card settling after the spike's reseats). Direction is the *safe* one (intermittent "damaged", not a false "good"), but unquantified. | **OPEN — Phase 5 determinism:** run `f3probe` ≥5×; require consistent `limbo`/122 MB. Tolerate occasional `damaged` (document rate); a single `good`/larger-size = hard stop, do not ship. | | 2026-05-31 | **Phase 5 determinism PASS.** 5 consecutive `f3probe --destructive /dev/disk10` runs all returned identical `limbo` / 122.00 MB / 249856 blocks. The earlier one-off `damaged` did **not** recur. | Determinism confirmed for this card+reader. The transient `damaged` is documented as a known, safe-direction (never a false `good`) intermittency at the extreme announced LBA — not reproduced in 5 runs. | +| 2026-05-31 | **A different (write-protected) SD card failed with a bare `f3probe: Can't open device `/dev/rdisk10': Permission denied`** under `sudo`, even after `diskutil unmountDisk` succeeded. Cause: the card's physical lock switch was engaged, so `open(O_RDWR)` returned `EACCES`. The macOS error handling masked this twice: (a) the only `EACCES` hint was gated behind `getuid()` truthy, so a **root** user (sudo, uid 0) fell straight through to the generic `err()`; (b) there was no write-protect/read-only detection at all. root bypasses classic UNIX DAC, so `EACCES` as root is really write-protected media or a TCC/Full-Disk-Access denial — neither was mentioned. | Reworked `create_block_device()` open-failure handling in `libdevs.c`. Added `darwin_media_is_write_protected()` (read-only open + `DKIOCISWRITABLE`, `#ifdef`-guarded, returns true only when definitely locked) and `darwin_warn_write_protected()`. `EACCES` now branches: write-protected → lock-switch message; non-root → existing "run as root" hint; **root → new message naming write-protect + Full Disk Access**. Added a proactive post-open check for bridges that open RW yet reject writes. `err()` path restores the saved `open_errno`. Not yet built on-Mac. **Verify:** lock ON → clear message, not `Permission denied`; lock OFF → probe proceeds. | diff --git a/README-macOS.md b/README-macOS.md index 4f9d4d0..4a844a0 100644 --- a/README-macOS.md +++ b/README-macOS.md @@ -59,6 +59,12 @@ sudo build/f3probe /dev/disk4 # pass the WHOLE disk, not a slice (diskN to the wrong `/dev/diskN` destroys data. - Probing writes only within `(1 MB, announced_end)` and (by default, non `--destructive`) restores the blocks it touched. +- **`Permission denied` even with `sudo`?** Two macOS-specific causes (the tool + now names both in the error): (1) the SD card's physical **lock switch** is + engaged — `f3probe` needs read-write access, so slide it away from `LOCK`; + confirm with `diskutil info /dev/diskN | grep -i read-only`. (2) Your terminal + lacks **Full Disk Access** — add it under System Settings › Privacy & Security › + Full Disk Access, then fully quit and reopen the terminal. ## You MUST validate before trusting it diff --git a/src/libdevs.c b/src/libdevs.c index bc748d2..4661e6e 100644 --- a/src/libdevs.c +++ b/src/libdevs.c @@ -1119,6 +1119,43 @@ static int darwin_disk_paths(const char *arg, char *whole, size_t wlen, return 0; } +/* Report whether the media behind @path is write-protected (e.g. an SD card + * with its physical lock switch engaged). f3probe needs read-write access, so + * such a card must be rejected with a clear message instead of a bare EACCES + * or a mid-probe write failure. + * + * A locked card still permits a read-only open, so query the driver via + * DKIOCISWRITABLE on an O_RDONLY fd. Returns 1 only when the media is + * definitely write-protected; 0 if writable or undeterminable, so the caller + * falls back to its generic handling and we never raise a false alarm. + */ +static int darwin_media_is_write_protected(const char *path) +{ +#ifdef DKIOCISWRITABLE + int writable = 0; + int fd = open(path, O_RDONLY); + if (fd < 0) + return 0; /* Can't even read it; let the caller decide. */ + if (ioctl(fd, DKIOCISWRITABLE, &writable) < 0) + writable = 1; /* ioctl unsupported: assume writable, don't false-alarm. */ + assert(!close(fd)); + return !writable; +#else + (void)path; + return 0; +#endif +} + +static void darwin_warn_write_protected(const char *path) +{ + fprintf(stderr, + "Device `%s' is write-protected.\n" + "If this is an SD card, slide the physical lock switch on the side of\n" + "the card (or its adapter) away from LOCK, then retry — f3probe must\n" + "open the device read-write.\n", + path); +} + struct device *create_block_device(const char *filename, enum reset_type rt) { struct block_device *bdev; @@ -1164,25 +1201,51 @@ struct device *create_block_device(const char *filename, enum reset_type rt) bdev->fd = bdev_open(bdev->filename); if (bdev->fd < 0) { - if (errno == EACCES && getuid()) { + int open_errno = errno; /* Preserve; the probe below clobbers errno. */ + if (open_errno == EACCES && + darwin_media_is_write_protected(bdev->filename)) { + darwin_warn_write_protected(bdev->filename); + } else if (open_errno == EACCES && getuid()) { fprintf(stderr, "Your user doesn't have access to device `%s'.\n" "Try to run this program as root:\n" " sudo %s %s\n" "In case you don't have access to root, use f3write/f3read.\n", bdev->filename, getprogname(), filename); - } else if (errno == EBUSY) { + } else if (open_errno == EACCES) { + /* Running as root but still EACCES. root bypasses classic UNIX + * permissions, so on macOS this is almost always write-protected + * media (handled above) or a TCC denial on the terminal. + */ + fprintf(stderr, + "Can't open device `%s': permission denied even as root.\n" + "On macOS this usually means either:\n" + " - the card is write-protected (check the SD lock switch), or\n" + " - the terminal running f3probe lacks Full Disk Access\n" + " (System Settings > Privacy & Security > Full Disk Access:\n" + " add your terminal app, then fully quit and reopen it).\n", + bdev->filename); + } else if (open_errno == EBUSY) { fprintf(stderr, "Device `%s' is busy (a volume is probably still mounted).\n" "Unmount the whole disk and retry:\n" " diskutil unmountDisk %s\n", bdev->filename, whole); } else { - err(errno, "Can't open device `%s'", bdev->filename); + errno = open_errno; + err(open_errno, "Can't open device `%s'", bdev->filename); } goto filename; } + /* Some bridges let a write-protected card open read-write yet fail every + * write. Reject it now with a clear message instead of deep in the probe. + */ + if (darwin_media_is_write_protected(bdev->filename)) { + darwin_warn_write_protected(bdev->filename); + goto fd; + } + /* Total size = block count * logical block size. * (Linux uses BLKGETSIZE64 + BLKSSZGET; this is the Darwin analogue.) */ From 1e47a002f1d22a659d0edcd567badb14889c1c77 Mon Sep 17 00:00:00 2001 From: mgarciaisaia Date: Thu, 4 Jun 2026 01:24:04 -0300 Subject: [PATCH 10/10] Drop development artifacts for macOS port They may be interesting to someone checking the port, but they don't make sense in the upstream repo if merged. --- PORT-LOG.md | 206 ---------------------------- README-macOS.md | 109 --------------- spike.c | 346 ------------------------------------------------ 3 files changed, 661 deletions(-) delete mode 100644 PORT-LOG.md delete mode 100644 README-macOS.md delete mode 100644 spike.c diff --git a/PORT-LOG.md b/PORT-LOG.md deleted file mode 100644 index 22e3002..0000000 --- a/PORT-LOG.md +++ /dev/null @@ -1,206 +0,0 @@ -# PORT-LOG — f3probe → macOS (Apple Silicon) - -> Running audit trail for the port described in `f3probe-macos-port-spec.md`. -> Append-only, table-first. **Read §0 first: it states what environment this -> work was actually done in, and therefore what is and is not proven.** - ---- - -## 0. Environment reality check (READ THIS FIRST) - -The spec assumes "a fresh Claude Code session running on the target Mac" with -physical test cards, card readers, and a Linux reference box. **None of that is -true of the environment this port was authored in.** Established facts: - -| Fact | Evidence | -|---|---| -| Host OS is **Linux aarch64** (a linuxkit container), **not macOS** | `uname -a` → `Linux … 6.12.76-linuxkit … aarch64`; `sw_vers` absent | -| **No C compiler / build tooling** | `cc`, `gcc`, `clang`, `make`, `xcode-select` all absent from `PATH` | -| **No macOS tooling** | `diskutil`, `brew`, `sw_vers` absent | -| **No hardware** | No flash cards, no USB readers, no `/dev/rdiskN` to touch | -| No prior macOS commits | branch `macos-version` == `master` == `origin/master` (commit `67b8a5d`) | - -**Consequence for "success":** the spec defines success *only* as the ported -binary's verdict matching ground truth across the Phase 4 validation battery on -real cards. **That cannot be performed here** — there is no compiler to build it, -no macOS to run it on, and no cards to probe. Per the spec's non-negotiable -principle, I will **not** claim validation I did not do. - -What this environment *can* produce, correctly and reviewably, is the -**environment-independent engineering**: read the seam, design the mapping, -write the Darwin device backend + the Phase-1 spike + the build changes as -source, and hand over a precise runbook the user executes on the Mac. That is -what this log covers. Everything below is **UNVALIDATED CODE** until Phase 1/4/5 -are run on the Mac by the user. - -Phase status in this environment: - -| Phase | What it needs | Status here | -|---|---|---| -| 0 Ground truth | Mac + cards + Linux box | ⛔ Cannot run (no hardware) — runbook provided | -| 1 Cache-defeat spike | compiler + Mac + fake card | 🟡 `spike.c` **written**, **not built/run** — runbook provided | -| 2 Map the seam | source only | ✅ Done (this log, §2) | -| 3 Implement Darwin backend | compiler to verify | 🟡 Code **written** (`libdevs.c`, `Makefile`), **not compiled** | -| 4 Correctness battery | Mac + cards + readers | ⛔ Cannot run — runbook provided | -| 5 Robustness | Mac + cards | ⛔ Cannot run — runbook provided | -| 6 Package fork | — | 🟡 README/runbook written | - ---- - -## 1. The most important finding (corrects a central premise of the spec) - -The spec says (§2.2, §3): *"a USB-level device reset (`USBDEVFS_RESET`) between -the write and verify phases … THAT USB reset is the crux of the whole port."* - -**For `f3probe`, this is not accurate.** Reading the actual source: - -- `f3probe.c:413` constructs the device with `create_block_device(filename, RT_NONE)`. -- `f3probe` exposes **no `--reset-type` option** at all (`f3probe.c:29-62`). -- `RT_NONE` → `bdev_none_reset()` → **does nothing** (`libdevs.c:818-822`). -- **`libprobe.c` contains zero references to reset** (`grep -ni reset libprobe.c` - → nothing). The probe algorithm never resets the device. -- The USB reset path (`bdev_usb_reset`/`bdev_manual_usb_reset`, `USBDEVFS_RESET`) - is used **only by `f3brew`** (`f3brew.c:562` default `RT_MANUAL_USB`, - `f3brew.c:614` `dev_reset`). **`f3brew` is explicitly out of scope.** - -How `f3probe` actually defeats caches (no hardware reset involved): - -1. **OS page cache** — `bdev_open()` uses `O_DIRECT` on Linux (`libdevs.c:474`). -2. **Post-write flush** — `bdev_write_blocks()` does `fsync()` + - `posix_fadvise(POSIX_FADV_DONTNEED)` (`libdevs.c:466-469`). -3. **Controller/card cache** — the algorithm *measures* the cache size - (`find_cache_size`) and then **`overwhelm_cache()`** (`libprobe.c:135-143`) - writes that many sequential blocks to flush stale data out, and issues only - **random reads** to dodge sequential-read caches (`libprobe.c:29-41`). - -**Why this matters:** the hardest, riskiest item the spec front-loads (IOKit USB -reset) is **not required for `f3probe`**. The port reduces to mapping three -primitives — unbuffered open, device-size query, and post-write flush — plus -dropping `libudev`. This is a much smaller and safer surface than the spec -feared. The cache-defeat correctness still must be proven empirically (Phase 1 -spike + Phase 4), because `overwhelm_cache` depends on correctly *measuring* the -reader/card cache, and a lying cache could still defeat it — so the validation -battery remains essential. But the *mechanism* to port is simpler. - ---- - -## 2. The seam — function-by-function mapping (Phase 2) - -Only `src/libdevs.c` is ported. `libprobe.c` is **untouched** (validated algorithm). -The platform-independent layers (`file_device`, `perf_device`, `safe_device`) are -untouched except they already use `aligned_alloc` (C11, present on macOS 10.15+). - -Backend contract used by `f3probe` (via `struct device` vtable, `libdevs.h`): - -| Backend op | Linux primitive (today) | Darwin replacement | Where | -|---|---|---|---| -| open (unbuffered) | `open(O_RDWR \| O_DIRECT)` | open **`/dev/rdiskN`** `O_RDWR` + `fcntl(F_NOCACHE,1)` | `bdev_open` | -| device size | `ioctl(BLKGETSIZE64)` | `ioctl(DKIOCGETBLOCKCOUNT)` × `ioctl(DKIOCGETBLOCKSIZE)` | `create_block_device` | -| logical block size | `ioctl(BLKSSZGET)` | `ioctl(DKIOCGETBLOCKSIZE)` | `create_block_device` | -| read blocks | `lseek`+`read` | identical (raw fd) | `bdev_read_blocks` (unchanged) | -| write blocks + flush | `lseek`+`write`+`fsync`+`fadvise(DONTNEED)` | `lseek`+`write`+`fcntl(F_FULLFSYNC)`+`ioctl(DKIOCSYNCHRONIZECACHE)` | `bdev_write_blocks` | -| reset | `bdev_none_reset` (no-op for `f3probe`) | identical no-op | `bdev_none_reset` (unchanged) | -| free / filename | `close`/`free` | identical | unchanged | -| device enumeration | `libudev` (whole-disk check, USB-backed check) | **dropped**: require path arg; reject slices by name; auto-`diskutil unmountDisk` | `create_block_device` (Darwin) | - -**Structure decision:** `#ifdef` branches **inside `libdevs.c`** (not a separate -`libdevs_darwin.c`). Reason: the `struct device` dispatch and the file/perf/safe -devices live in `libdevs.c` and are shared; only the includes, `bdev_open`, the -size query, the write-flush tail, and `create_block_device` differ. All Linux -`udev`/`USBDEVFS` code is wrapped in `#ifdef __linux__`; a self-contained Darwin -`create_block_device` is added under `#if defined(__APPLE__) && defined(__MACH__)`. -`libprobe.c` stays byte-for-byte identical. - -**Alignment note (validation risk):** raw-device I/O on macOS requires -offset/length to be block-size multiples — satisfied (`pos << block_order`). The -spec recommends *page*-aligned buffers; `f3` aligns buffers to *block* size only -(`align_mem` in `libprobe.c`, `aligned_alloc` in `libflow.c`). On `/dev/rdiskN` -block alignment is normally sufficient, but **if raw reads/writes return EINVAL** -this is the first thing to check (see §5 of the spec). Fixing it would require -touching `libflow.c`'s `dbuf` allocation and `libprobe.c`'s stack buffers — flag -to the user rather than silently changing the forbidden file. - ---- -## 3. Implementation (Phase 3) — written, NOT compiled - -All changes are isolated to the device backend and the build. `libprobe.c` is -**byte-for-byte unchanged** (`git diff --stat` shows it untouched). - -| File | Change | -|---|---| -| `src/libdevs.c` | `_DARWIN_C_SOURCE`; guard ``/`` behind `__linux__`, add `` for Darwin; `bdev_open` uses raw node + `F_NOCACHE` instead of `O_DIRECT`; `bdev_write_blocks` uses `F_FULLFSYNC` + `DKIOCSYNCHRONIZECACHE`; all udev/USBDEVFS code wrapped in `#ifdef __linux__`; a self-contained Darwin `create_block_device()` (path parsing → reject slices, `diskutil unmountDisk`, `DKIOCGETBLOCKCOUNT`×`DKIOCGETBLOCKSIZE`, `RT_NONE` only). | -| `Makefile` | drop `-ludev` on non-Linux (`UDEV_LIBS`); on Darwin `EXTRA_TARGETS` = just `f3probe`; argp path prefers `brew --prefix argp-standalone`. | -| `spike.c` (new) | standalone Phase-1 cache-defeat spike (see §4). | - -**Static review done (no compiler available):** preprocessor guards balanced; -no Linux-only symbol (`O_DIRECT`, `udev_*`, `USBDEVFS_RESET`, `BLKGETSIZE64`, -`BLKSSZGET`, `__progname`) appears outside an `#ifdef __linux__`; goto-cleanup -ladder in the Darwin `create_block_device` is sound; all referenced symbols -(`F_NOCACHE`, `F_FULLFSYNC`, `DKIOC*`, `getprogname`, `system`) are reachable -via the included headers under `_DARWIN_C_SOURCE`. **This is NOT a substitute -for compiling.** Checkpoint 3A (clean compile on arm64) is **NOT met** here — -it must be done on the Mac. - -## 4. What the user must run on the Mac (Phases 0,1,3A,4,5 — the real success bar) - -This is the runbook. **None of it has been executed here.** Until Phase 1 and -Phase 4 pass, the binary's verdicts must NOT be trusted (spec §0). - -**Build (Checkpoint 3A):** -``` -brew install argp-standalone -make extra # on macOS this now builds ONLY build/f3probe -cc -O2 -Wall -Wextra -o spike spike.c -``` -If `make` errors, paste the errors back — this is exactly the verification this -environment could not do. Watch for: missing `argp.h` (fix the brew prefix), -raw-I/O `EINVAL` (the buffer page-alignment caveat in §2), or a `DKIOC` symbol -not found (check `` on the installed SDK). - -**Phase 0 — ground truth (answer key BEFORE trusting the port):** for each card, -record announced size, and the verdict from BOTH (a) macOS `f3write`+`f3read` -and (b) upstream `f3probe` on the Linux box. They must agree. - -**Phase 1 — cache-defeat spike (⚠️ most important):** -``` -sudo ./spike disk4 # dry run: prints the plan + size, writes nothing -sudo ./spike --destroy disk4 # genuine card: VERDICT should say NO ALIASING -sudo ./spike --destroy disk9 # known-fake card: VERDICT should say ALIASING -``` -Then do the printed physical cross-check (eject/reseat + `dd` block 0). The -in-process VERDICT must match the post-reseat `dd` read. If it doesn't, a cache -lied on this reader → STOP, try another reader, do not ship. - -**Phase 4 — correctness battery:** for every (card × reader), run -`sudo build/f3probe /dev/disk4` and confirm `fake_type` + real size match BOTH -references from Phase 0. Include the reseat/`dd` boundary check and the -double-blind A/B test. Record every run. - -**Phase 5 — robustness:** determinism (probe twice), mounted-card handling, -no-sudo error message, Ctrl-C cleanliness, card-still-usable-after, 512B vs 4K -logical, 2 TB timing sanity. - -See `README-macOS.md` for the same steps in fork-README form, plus the results -table to fill in and the honest "validated / not validated" statement. - -## 5. Honest status summary - -- ✅ Done here: seam analysis, the RT_NONE/no-reset correction, the Darwin code, - build changes, the spike, docs. -- ⛔ Not done here (impossible without a Mac/compiler/cards): compile, Phase 1 - spike run, Phase 4 correctness, Phase 5 robustness. -- **Therefore this port is UNVALIDATED.** It is a credible, reviewed starting - point — not a trustworthy fraud-detection binary until the runbook passes. - -## 6. Phase 1 field findings (from on-Mac runs by the user) - -| Date | Finding | Action | -|---|---|---| -| 2026-05-31 | **`fcntl(fd, F_FULLFSYNC)` fails with `ENOTTY` on `/dev/rdiskN`.** F_FULLFSYNC is only valid on regular files; on a raw device node it is "inappropriate ioctl for device". The spike aborted at the first flush; `f3probe`'s `bdev_write_blocks` would have failed identically. | Drop F_FULLFSYNC on the raw node. Use `DKIOCSYNCHRONIZECACHE` only (the drive-cache flush, analogue of Linux fsync-on-block-device). Tolerate `ENOTTY`/`ENOTSUP` (reader without SYNCHRONIZE CACHE) with a one-time warning; surface other errno. Fixed in `spike.c` `flush_dev()` and `libdevs.c` `bdev_write_blocks()`. | -| 2026-05-31 | Raw **write to block 0 succeeded** before the flush error → no buffer-alignment/`EINVAL` problem on this reader (the page-alignment caveat in §2 did not bite). `diskutil unmountDisk` worked. | None; positive signal. | -| 2026-05-31 | **Phase 1 cache-defeat PASS on the limbo card.** After the flush fix: block 0 in-process read = `A`, post-reseat `dd` = `A` (match). Wrote `B` to the first limbo block (#249856); in-process read came back as **zeros (not `B`)** and post-reseat `dd` = zeros (match) — the discarded limbo write never returned from cache, in-process or physically. No cache lied on this reader. Ground truth (Linux upstream f3probe, same card): `limbo`, usable 122.00 MB / 249856 blocks, last good block 249855, module 2^41, ~512 MB cache. | Cache-defeat recipe **confirmed**: `/dev/rdiskN` + `F_NOCACHE` + `DKIOCSYNCHRONIZECACHE` (no USB reset). Caveat: single-point test; the definitive device-cache-defeat proof is f3probe (with `overwhelm_cache`) matching ground truth — next. Genuine-card half of Checkpoint 1 still pending. | -| 2026-05-31 | First `f3probe` build error on the diagnostic: `getpagesize()` undeclared under `_POSIX_C_SOURCE`. | Use `sysconf(_SC_PAGESIZE)` (core POSIX, always declared). Fixed. | -| 2026-05-31 | **Phase 4 verdict MATCH (limbo card, reader as used).** `f3probe --destructive --time-ops /dev/disk10` → `limbo`, usable **122.00 MB / 249856 blocks**, last good 249855, module 2^41, announced 1.95 TB — **identical to the Linux ground truth**. Probe time 1'31". Note: macOS reported cache size **0** (vs Linux 512 MB); verdict unaffected — consistent with the spike (no cache lie on this reader), so `overwhelm_cache` wasn't needed and the probe was ~15× faster. | Port correctly classifies this counterfeit on macOS. ✅ (one card, one reader) | -| 2026-05-31 | **Non-determinism observed (must characterize).** The run immediately before the match — *same I/O code* — returned `damaged / 0 blocks` because the first write (last announced block, offset ~2 TB) failed twice, then the probe bailed. The next run's writes succeeded and it completed correctly. Likely a flaky cheap-fake/bridge at the extreme announced LBA (perhaps aggravated by the card settling after the spike's reseats). Direction is the *safe* one (intermittent "damaged", not a false "good"), but unquantified. | **OPEN — Phase 5 determinism:** run `f3probe` ≥5×; require consistent `limbo`/122 MB. Tolerate occasional `damaged` (document rate); a single `good`/larger-size = hard stop, do not ship. | -| 2026-05-31 | **Phase 5 determinism PASS.** 5 consecutive `f3probe --destructive /dev/disk10` runs all returned identical `limbo` / 122.00 MB / 249856 blocks. The earlier one-off `damaged` did **not** recur. | Determinism confirmed for this card+reader. The transient `damaged` is documented as a known, safe-direction (never a false `good`) intermittency at the extreme announced LBA — not reproduced in 5 runs. | -| 2026-05-31 | **A different (write-protected) SD card failed with a bare `f3probe: Can't open device `/dev/rdisk10': Permission denied`** under `sudo`, even after `diskutil unmountDisk` succeeded. Cause: the card's physical lock switch was engaged, so `open(O_RDWR)` returned `EACCES`. The macOS error handling masked this twice: (a) the only `EACCES` hint was gated behind `getuid()` truthy, so a **root** user (sudo, uid 0) fell straight through to the generic `err()`; (b) there was no write-protect/read-only detection at all. root bypasses classic UNIX DAC, so `EACCES` as root is really write-protected media or a TCC/Full-Disk-Access denial — neither was mentioned. | Reworked `create_block_device()` open-failure handling in `libdevs.c`. Added `darwin_media_is_write_protected()` (read-only open + `DKIOCISWRITABLE`, `#ifdef`-guarded, returns true only when definitely locked) and `darwin_warn_write_protected()`. `EACCES` now branches: write-protected → lock-switch message; non-root → existing "run as root" hint; **root → new message naming write-protect + Full Disk Access**. Added a proactive post-open check for bridges that open RW yet reject writes. `err()` path restores the saved `open_errno`. Not yet built on-Mac. **Verify:** lock ON → clear message, not `Permission denied`; lock OFF → probe proceeds. | diff --git a/README-macOS.md b/README-macOS.md deleted file mode 100644 index 4a844a0..0000000 --- a/README-macOS.md +++ /dev/null @@ -1,109 +0,0 @@ -# f3probe on macOS (Apple Silicon) — fork notes - -This fork adds a **Darwin device backend** so `f3probe` builds and runs natively -on macOS / Apple Silicon. Upstream marks `f3probe`/`f3brew`/`f3fix` as -Linux-only because the device backend used Linux-kernel interfaces -(`O_DIRECT`, `BLK*` ioctls, `USBDEVFS_RESET`, `libudev`). Only **`f3probe`** is -ported here; `f3brew` and `f3fix` remain Linux-only (out of scope). - -> ⚠️ **Validation status: validated on ONE card + ONE reader; not yet proven -> beyond that.** The port compiles cleanly on Apple Silicon and, on a known -> **limbo** counterfeit ("1.95 TB" announced / 122 MB real) through one USB -> reader, produces a verdict **identical to Linux upstream f3probe** and stable -> across 5 consecutive runs; the spike's eject/reseat `dd` cross-check confirmed -> it reads physical media, not cache. It has **NOT** been tested on a genuine -> device, on other fake archetypes (wraparound/chain/bad), on other readers, or -> on 4K-logical media. `f3probe` is a fraud-detection tool — treat untested -> configurations as unproven. See the results table and limits below, and -> `PORT-LOG.md` for the full chain of evidence. - -## What changed (and what didn't) - -- **Unchanged:** `src/libprobe.c` — the validated probing algorithm. Not touched. -- **Ported:** `src/libdevs.c` — only the block-device backend, behind - `#ifdef __APPLE__` / `#ifdef __linux__`: - | Purpose | Linux | macOS (this fork) | - |---|---|---| - | unbuffered open | `O_DIRECT` | open `/dev/rdiskN` + `fcntl(F_NOCACHE,1)` | - | device size | `BLKGETSIZE64` | `DKIOCGETBLOCKCOUNT` × `DKIOCGETBLOCKSIZE` | - | post-write flush | `fsync`+`FADV_DONTNEED` | `F_FULLFSYNC` + `DKIOCSYNCHRONIZECACHE` | - | enumeration | `libudev` | drop it; pass the device path; auto `diskutil unmountDisk` | -- **Build:** `Makefile` drops `-ludev` on non-Linux and, on macOS, builds only - `f3probe` among the extra tools. - -**Note on the USB reset:** `f3probe` constructs its device with `RT_NONE` and -its algorithm never calls reset — it defeats caches with unbuffered raw I/O plus -its own `overwhelm_cache` step, not with a hardware reset. So the IOKit/USB -reset that the porting notes feared is **not needed for `f3probe`** (it is an -`f3brew` feature). This is why the port is small. - -## Build (Apple Silicon) - -```sh -brew install argp-standalone # provides argp.h / libargp.a -make extra # builds build/f3probe (macOS: f3probe only) -make all # f3write / f3read (already supported upstream) -``` -Homebrew on Apple Silicon lives under `/opt/homebrew`; the Makefile resolves it -via `brew --prefix argp-standalone`. If linking fails, confirm the static lib -name and path: `ls "$(brew --prefix argp-standalone)/lib"`. - -## Usage & safety - -```sh -sudo build/f3probe /dev/disk4 # pass the WHOLE disk, not a slice (diskNsM) -``` -- Raw disk access needs `sudo`. Without it you get a clear "no access" message. -- The tool opens the **raw** node `/dev/rdisk4` and `diskutil unmountDisk`s the - whole disk first. **Double-check the device** with `diskutil list` — writing - to the wrong `/dev/diskN` destroys data. -- Probing writes only within `(1 MB, announced_end)` and (by default, non - `--destructive`) restores the blocks it touched. -- **`Permission denied` even with `sudo`?** Two macOS-specific causes (the tool - now names both in the error): (1) the SD card's physical **lock switch** is - engaged — `f3probe` needs read-write access, so slide it away from `LOCK`; - confirm with `diskutil info /dev/diskN | grep -i read-only`. (2) Your terminal - lacks **Full Disk Access** — add it under System Settings › Privacy & Security › - Full Disk Access, then fully quit and reopen the terminal. - -## You MUST validate before trusting it - -`f3probe` builds cleanly ≠ `f3probe` is correct. Run, in order: - -1. **Ground truth** — for each card, get an independent verdict from macOS - `f3write`+`f3read` and from upstream `f3probe` on a Linux box. They must agree. -2. **Cache-defeat spike** — `cc -O2 -Wall -Wextra -o spike spike.c`, then - `sudo ./spike --destroy diskN` on a known-genuine and a known-fake card, and - do the printed eject/reseat + `dd` cross-check. The in-process verdict must - match the physical `dd` read. If not, the reader's cache is lying → use a - different reader or stop. -3. **Correctness battery** — for every (card × reader), compare `f3probe`'s full - verdict (`fake_type`, real size, wraparound, block order) against both - references. Add the double-blind A/B test on two same-announced-size cards. -4. **Robustness** — determinism, mounted-card handling, no-sudo error, Ctrl-C, - card-usable-after, 512B vs 4K logical, 2 TB timing. - -### Results table - -| Card | Announced | Reader | Linux f3probe (ground truth) | macOS f3probe | Match? | -|------|-----------|--------|------------------------------|---------------|--------| -| limbo fake | 1.95 TB (4194304000 × 512) | USB reader (model TBD — fill in) | `limbo`, usable 122.00 MB / 249856 blk, last 249855, module 2^41 | `limbo`, usable 122.00 MB / 249856 blk, last 249855, module 2^41 — **5/5 runs identical** | ✅ | -| _genuine card_ | | | | _not yet tested_ | — | -| _other fake archetypes_ | | | | _not yet tested_ | — | - -Notes: on this card macOS reported cache size 0 (probe ~1.5 min) vs Linux 512 MB -(~23 min); the verdict was unaffected. The spike's eject/reseat `dd` cross-check -confirmed the read path reflects physical media. One earlier run returned a -transient `damaged` (a write error at the extreme announced LBA) that did not -recur in 5 subsequent runs — a safe-direction intermittency (never a false -`good`), documented in `PORT-LOG.md`. - -### Honest limits statement - -> **Validated:** one **limbo** counterfeit ("1.95 TB" announced, 122 MB real) -> via one USB reader, against Linux upstream f3probe — deterministic over 5 runs, -> and confirmed reading physical media (spike cross-check). -> **NOT proven:** genuine devices; other fake archetypes (wraparound, chain, -> bad); other USB readers or the built-in SD slot; 4K-logical-sector devices; -> behaviour under interrupt/again-after-reformat (Phase 5 leftovers). -> Treat all of the above as unsupported until tested. diff --git a/spike.c b/spike.c deleted file mode 100644 index bbdc57d..0000000 --- a/spike.c +++ /dev/null @@ -1,346 +0,0 @@ -/* - * spike.c — Phase 1 cache-defeat spike for the f3probe macOS port. - * - * This is NOT part of f3. It is the throwaway de-risking program the porting - * spec front-loads: before trusting a ported f3probe, prove that on THIS Mac + - * THIS reader + THIS card, raw unbuffered I/O actually reflects what is - * physically on the flash — so that on a fake card we can OBSERVE address - * aliasing, and no cache silently returns matching-but-stale data. - * - * What it does (mirrors the spec's "killer test"): - * 1. unmount the whole disk, open /dev/rdiskN with F_NOCACHE, - * 2. read the device size via DKIOC* ioctls, - * 3. write pattern A to block 0, flush, read block 0 (sanity), - * 4. write pattern B to the LAST announced block, flush, read block 0 again. - * - block 0 still == A -> no aliasing at this offset (genuine, or real size - * larger than the probed offset), - * - block 0 now == B -> the high write landed on block 0: ALIASING -> fake, - * - anything else -> unexpected; investigate. - * It also reads the last block back and reports it. - * - * The decisive cache check is the PHYSICAL cross-check the program prints at the - * end: after it finishes, physically eject + reseat the card and `dd` block 0; - * the bytes dd reads MUST match what this program reported. If they differ, a - * cache lied and the port cannot be trusted on this reader (escalate per spec). - * - * DESTRUCTIVE: it overwrites block 0 (partition-table area) and the last block - * of the target disk. It WILL destroy data on that disk. - * - * Build: cc -O2 -Wall -Wextra -o spike spike.c - * Usage: sudo ./spike disk4 # DRY RUN: print the plan, write nothing - * sudo ./spike --destroy disk4 # actually write (irreversible) - * - * Exit: 0 = completed (read the printed VERDICT); non-zero = setup/IO error. - */ - -#define _DARWIN_C_SOURCE - -#if !(defined(__APPLE__) && defined(__MACH__)) -#error "spike.c is macOS-only; it uses F_NOCACHE/F_FULLFSYNC and the DKIOC* ioctls." -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define TAG_A 0xAAAAAAAAAAAAAAAAULL /* written to block 0 */ -#define TAG_B 0xBBBBBBBBBBBBBBBBULL /* written to the high block */ - -/* Fill @buf with repeating 16-byte records of {tag, block_idx} so a read-back - * tells us unambiguously which write produced the bytes we see. */ -static void fill_pattern(unsigned char *buf, size_t len, - uint64_t tag, uint64_t block_idx) -{ - size_t off; - memset(buf, 0, len); - for (off = 0; off + 16 <= len; off += 16) { - memcpy(buf + off, &tag, 8); - memcpy(buf + off + 8, &block_idx, 8); - } -} - -static void read_record(const unsigned char *buf, uint64_t *tag, uint64_t *idx) -{ - memcpy(tag, buf, 8); - memcpy(idx, buf + 8, 8); -} - -/* Derive "/dev/diskN" (whole) and "/dev/rdiskN" (raw) from a user argument. - * Accepts diskN, rdiskN, /dev/diskN, /dev/rdiskN. Rejects slices (diskNsM). */ -static int disk_paths(const char *arg, char *whole, size_t wl, char *raw, size_t rl) -{ - const char *p = arg, *q; - if (!strncmp(p, "/dev/", 5)) - p += 5; - if (p[0] == 'r' && !strncmp(p + 1, "disk", 4)) - p++; - if (strncmp(p, "disk", 4)) { - fprintf(stderr, "`%s' is not a macOS disk (expected e.g. /dev/disk4)\n", arg); - return -1; - } - q = p + 4; - if (*q < '0' || *q > '9') { - fprintf(stderr, "`%s' has no disk number\n", arg); - return -1; - } - while (*q >= '0' && *q <= '9') - q++; - if (*q != '\0') { - fprintf(stderr, "`%s' looks like a partition/slice; use the WHOLE disk " - "(e.g. /dev/disk4, not /dev/disk4s1)\n", arg); - return -1; - } - snprintf(whole, wl, "/dev/%s", p); - snprintf(raw, rl, "/dev/r%s", p); - return 0; -} - -static int flush_dev(int fd) -{ - /* On a RAW disk node, F_FULLFSYNC is not applicable (it returns ENOTTY); - * the drive's write cache is flushed with DKIOCSYNCHRONIZECACHE. This is - * best-effort: if the reader doesn't implement SYNCHRONIZE CACHE we note it - * and continue — the raw write already left the OS, and the physical - * eject/reseat cross-check is the real arbiter of whether a cache lied. */ -#ifdef DKIOCSYNCHRONIZECACHE - if (ioctl(fd, DKIOCSYNCHRONIZECACHE) < 0) - fprintf(stderr, " note: DKIOCSYNCHRONIZECACHE failed (%s); continuing\n", - strerror(errno)); -#else - (void)fd; - fprintf(stderr, " note: built without DKIOCSYNCHRONIZECACHE; no cache flush\n"); -#endif - return 0; -} - -/* Seek + full write of exactly @len bytes at block @idx. */ -static int write_block(int fd, uint64_t idx, const unsigned char *buf, uint32_t bsz) -{ - off_t want = (off_t)idx * bsz, got = lseek(fd, want, SEEK_SET); - size_t done = 0; - if (got != want) { perror("lseek(write)"); return -1; } - while (done < bsz) { - ssize_t rc = write(fd, buf + done, bsz - done); - if (rc < 0) { perror("write"); return -1; } - done += (size_t)rc; - } - return 0; -} - -static int read_block(int fd, uint64_t idx, unsigned char *buf, uint32_t bsz) -{ - off_t want = (off_t)idx * bsz, got = lseek(fd, want, SEEK_SET); - size_t done = 0; - if (got != want) { perror("lseek(read)"); return -1; } - while (done < bsz) { - ssize_t rc = read(fd, buf + done, bsz - done); - if (rc < 0) { perror("read"); return -1; } - if (rc == 0) { fprintf(stderr, "unexpected EOF reading block %llu\n", - (unsigned long long)idx); return -1; } - done += (size_t)rc; - } - return 0; -} - -static const char *tag_name(uint64_t tag) -{ - if (tag == TAG_A) return "A (block 0)"; - if (tag == TAG_B) return "B (high block)"; - return "??? (neither A nor B)"; -} - -int main(int argc, char **argv) -{ - const char *arg = NULL; - bool destroy = false, have_real = false; - char whole[64], raw[64], cmd[160]; - int fd, i; - uint32_t bsz = 0; - uint64_t bcount = 0, total, last_block, high_block, real_size = 0; - const char *high_desc; - unsigned char *bufA, *bufB, *rb; - uint64_t tag, idx; - - for (i = 1; i < argc; i++) { - if (!strcmp(argv[i], "--destroy")) destroy = true; - else if (!strncmp(argv[i], "--real-size=", 12)) { - char *end; - real_size = strtoull(argv[i] + 12, &end, 0); - if (*end != '\0') { - fprintf(stderr, "bad --real-size value `%s'\n", argv[i] + 12); - return 2; - } - have_real = true; - } - else if (argv[i][0] == '-') { - fprintf(stderr, "unknown option `%s'\n", argv[i]); - return 2; - } else arg = argv[i]; - } - if (!arg) { - fprintf(stderr, - "usage: sudo %s [--destroy] [--real-size=BYTES] diskN\n" - " (without --destroy this is a DRY RUN: it prints the plan only)\n" - " --real-size=BYTES: announced offset at which to write pattern B.\n" - " For a known WRAPAROUND fake, set this to the card's real\n" - " capacity (from f3write/f3read) so the high write aliases onto\n" - " block 0. Default: the last announced block, which often does\n" - " NOT alias onto block 0. Shell tip: --real-size=$((64*1024**3)).\n", - argv[0]); - return 2; - } - if (disk_paths(arg, whole, sizeof whole, raw, sizeof raw)) - return 2; - - printf("Target whole disk : %s\n", whole); - printf("Target raw node : %s\n", raw); - - /* Read geometry first (non-destructive) so the dry run can show real sizes. */ - if (!destroy) { - printf("\n*** DRY RUN — no writes performed. ***\n" - "This program will, with --destroy:\n" - " 1. diskutil unmountDisk %s\n" - " 2. open %s with F_NOCACHE\n" - " 3. write pattern A to block 0, flush, read block 0 back\n" - " 4. write pattern B to the high block (default: last block; or the\n" - " wrap boundary if --real-size=BYTES is given), flush, re-read block 0\n" - "It OVERWRITES block 0 and the high block (DESTRUCTIVE).\n" - "Re-run as: sudo %s --destroy %s\n", whole, raw, argv[0], arg); - /* Still try to open read-only to report the size, best-effort. */ - fd = open(raw, O_RDONLY); - if (fd >= 0) { - if (!ioctl(fd, DKIOCGETBLOCKCOUNT, &bcount) && - !ioctl(fd, DKIOCGETBLOCKSIZE, &bsz) && bsz) - printf("\nGeometry: %llu blocks x %u bytes = %llu bytes (%.2f GB announced)\n", - (unsigned long long)bcount, bsz, - (unsigned long long)(bcount * (uint64_t)bsz), - (bcount * (double)bsz) / 1e9); - close(fd); - } else { - printf("\n(Could not open %s read-only to read its size: %s.\n" - " You likely need sudo; that is expected.)\n", raw, strerror(errno)); - } - return 0; - } - - snprintf(cmd, sizeof cmd, "diskutil unmountDisk %s", whole); - printf("\nRunning: %s\n", cmd); - if (system(cmd) != 0) - fprintf(stderr, "warning: `%s' did not return 0; open may fail (EBUSY)\n", cmd); - - fd = open(raw, O_RDWR); - if (fd < 0) { fprintf(stderr, "open(%s): %s\n", raw, strerror(errno)); return 1; } - if (fcntl(fd, F_NOCACHE, 1) < 0) { perror("fcntl(F_NOCACHE)"); close(fd); return 1; } - - if (ioctl(fd, DKIOCGETBLOCKCOUNT, &bcount) < 0) { perror("DKIOCGETBLOCKCOUNT"); close(fd); return 1; } - if (ioctl(fd, DKIOCGETBLOCKSIZE, &bsz) < 0) { perror("DKIOCGETBLOCKSIZE"); close(fd); return 1; } - if (!bsz || bcount < 2) { fprintf(stderr, "device too small or bad block size\n"); close(fd); return 1; } - total = bcount * (uint64_t)bsz; - last_block = bcount - 1; - printf("Geometry: %llu blocks x %u bytes = %llu bytes (%.2f GB announced)\n", - (unsigned long long)bcount, bsz, (unsigned long long)total, total / 1e9); - - high_block = last_block; - high_desc = "the LAST announced block"; - if (have_real) { - if (real_size == 0 || real_size >= total) { - fprintf(stderr, "--real-size=%llu must be > 0 and < announced size %llu\n", - (unsigned long long)real_size, (unsigned long long)total); - close(fd); return 2; - } - /* The first announced block at/after the real boundary. On a clean - * wraparound fake this physical-aliases onto block 0. */ - high_block = real_size / bsz; - if (high_block == 0 || high_block >= bcount) { - fprintf(stderr, "computed high block %llu is out of range\n", - (unsigned long long)high_block); - close(fd); return 2; - } - high_desc = "the wrap boundary (first block past real size)"; - } - printf("Block 0 offset = 0; high block #%llu offset = %llu bytes (%s)\n", - (unsigned long long)high_block, - (unsigned long long)(high_block * (uint64_t)bsz), high_desc); - - /* Page-aligned buffers (spec belt-and-suspenders for raw I/O). */ - if (posix_memalign((void **)&bufA, (size_t)getpagesize(), bsz) || - posix_memalign((void **)&bufB, (size_t)getpagesize(), bsz) || - posix_memalign((void **)&rb, (size_t)getpagesize(), bsz)) { - fprintf(stderr, "posix_memalign failed\n"); close(fd); return 1; - } - fill_pattern(bufA, bsz, TAG_A, 0); - fill_pattern(bufB, bsz, TAG_B, high_block); - - /* Step 3: A -> block 0, flush, read back (must see A). */ - printf("\n[3] Writing pattern A to block 0 ...\n"); - if (write_block(fd, 0, bufA, bsz) || flush_dev(fd) || read_block(fd, 0, rb, bsz)) goto io_err; - read_record(rb, &tag, &idx); - if (tag != TAG_A) { - printf("VERDICT: SETUP FAILURE — wrote A to block 0 but read back %s.\n" - "The basic write/read path is not working; do not trust further results.\n", - tag_name(tag)); - goto done_fail; - } - printf(" OK: block 0 reads back as pattern A.\n"); - - /* Step 4: B -> high block, flush, re-read block 0. */ - printf("[4] Writing pattern B to %s (#%llu) ...\n", - high_desc, (unsigned long long)high_block); - if (write_block(fd, high_block, bufB, bsz) || flush_dev(fd)) goto io_err; - if (read_block(fd, 0, rb, bsz)) goto io_err; - read_record(rb, &tag, &idx); - printf(" Block 0 now holds: tag=%s idx=%llu\n", tag_name(tag), (unsigned long long)idx); - - printf("\n================= VERDICT =================\n"); - if (tag == TAG_A) { - printf("NO ALIASING onto block 0: the high write did not corrupt block 0.\n" - "This alone does NOT mean the card is genuine: limbo-type fakes don't\n" - "corrupt low blocks, and the wrap boundary may map elsewhere than block 0.\n" - "If this is a known WRAPAROUND fake, re-run with --real-size= so B lands on the wrap boundary.\n" - "The cross-check below still confirms whether the read path is honest.\n"); - } else if (tag == TAG_B) { - printf("ALIASING DETECTED: writing the high block overwrote block 0.\n" - "This is the wraparound signature of a FAKE device, and the\n" - "in-process unbuffered read SAW it — caches did not hide it.\n"); - } else { - printf("UNEXPECTED: block 0 holds neither A nor B. Investigate (partial\n" - "aliasing, a lying cache, or a flaky reader).\n"); - } - printf("==========================================\n"); - - /* Read the high block back too, for the record. */ - if (!read_block(fd, high_block, rb, bsz)) { - read_record(rb, &tag, &idx); - printf("(For reference, high block #%llu reads back as: tag=%s idx=%llu)\n", - (unsigned long long)high_block, tag_name(tag), (unsigned long long)idx); - } - - close(fd); - printf("\n*** PHYSICAL CROSS-CHECK (do this to be sure no cache lied) ***\n" - "1. Eject, then PHYSICALLY power-cycle the card (unplug the reader or pull\n" - " the card out and reinsert) so the reader and card caches are wiped:\n" - " diskutil eject %s # then physically unplug & replug\n" - "2. The card may come back as a DIFFERENT node — re-check with: diskutil list\n" - "3. Read block 0 straight off the media and dump the first record\n" - " (use the raw rNODE so the OS cache is bypassed; sudo required):\n" - " sudo dd if=%s bs=%u count=1 2>/dev/null | xxd | head -1\n" - " First 8 bytes = the tag: aaaaaaaa.. = A (block 0), bbbbbbbb.. = B (high\n" - " block). It MUST match the VERDICT above. If it differs, a cache lied on\n" - " this reader and the port cannot be trusted here (try another reader or\n" - " escalate per the spec).\n", whole, raw, bsz); - return 0; - -io_err: - fprintf(stderr, "I/O error during the spike; results are inconclusive.\n"); -done_fail: - close(fd); - return 1; -}