diff --git a/.beans/ATFS-c2ny--repair-story-for-a-corrupted-atfsdata-volume.md b/.beans/ATFS-c2ny--repair-story-for-a-corrupted-atfsdata-volume.md index 5e0540e..0e5896f 100644 --- a/.beans/ATFS-c2ny--repair-story-for-a-corrupted-atfsdata-volume.md +++ b/.beans/ATFS-c2ny--repair-story-for-a-corrupted-atfsdata-volume.md @@ -102,13 +102,12 @@ force-format API. Left unstarted here rather than half-built. "harmless" because the drive is atfs's own from then on, which is exactly the case that now also gets rebuilt. It says remove it after a successful wipe, and why, until the flag question below is settled -- [ ] Decide the flag question below: one flag with an honest name, or two -- [ ] gosd (small): a distinct sentinel for the unmountable refusal, so atfs - can tell it from foreign content without matching error text -- [ ] atfs-gosd: gate a mode-2 rebuild on ATFS_REBUILD_UNHEALTHY_DRIVE, - mirroring resolveRefusedFormat ergonomics (blocked on the above) -- [ ] Update probeWritable remediation text to name the flag (with the - above) +- [x] Decide the flag question: **two flags** (JP, 2026-08-09) +- [x] gosd: a distinct sentinel for the unmountable refusal (gosd-p8ai, PR #255) +- [x] atfs: gate the rebuild on ATFS_REBUILD_UNHEALTHY_DRIVE, mirroring + resolveRefusedFormat's ergonomics +- [x] ~~Update probeWritable remediation text to name the flag~~ — wrong, see + below: neither flag can help the case probeWritable fires on - [ ] Bench-verify a mode-2 rebuild: poison the volume read-only, watch the restart loop, set the flag, confirm rebuild + preserved peer ID @@ -270,3 +269,47 @@ Two ways to go, JP's call: Mode 2 (mounts, then fails probeWritable) is untouched by any of this and still has no in-band remedy. + +## Two flags, shipped (2026-08-09) + +gosd-p8ai gave the unmountable refusal its own sentinel, so atfs can finally +tell "someone else's data is here" from "your own volume is sick" without +matching on error text. `ResolveDataDir` now routes them apart, and the +ordering is load-bearing: `ErrUnmountable` is wrapped *alongside* +`ErrRefusedFormat` in the same error, so it must be matched first or the +broader arm swallows it and hands atfs's own volume to the wrong flag. Both +the switch and the doc comments say so. + +- `ATFS_FORMAT_DRIVES_IF_NOT_ATFS` gets its honest promise back: it only ever + overwrites a drive atfs doesn't already recognize as its own, so leaving it + set risks nothing later. The README's retraction (b432858) is reverted to a + plain statement, now that it is true again. +- `ATFS_REBUILD_UNHEALTHY_DRIVE` is new and covers only the narrow case. Its + messaging is deliberately starker in both directions, because the cost is + different in kind: the rebuild destroys every blob *and* the identity key, + so the node returns with a new peer ID and its dev.atfs.server record — + rkey = the old peer ID — is orphaned and must be recreated by hand. + +The test that matters most is the one pinning that +`ATFS_FORMAT_DRIVES_IF_NOT_ATFS=true` alone does **not** authorize a rebuild: +that separation is the entire point of the split. + +### The probeWritable todo was wrong + +It has been on this list since 2026-08-07 and does not survive contact with +the design. `probeWritable` fires when the volume **mounted successfully** +and then turned out to be unwritable — the read-only remount, mode 2. gosd +returned no error at all in that case, so neither flag is ever consulted. +Naming `ATFS_REBUILD_UNHEALTHY_DRIVE` in its remediation text would send an +operator to a switch that cannot help them. Left alone deliberately. + +## What's left: mode 2 only + +Mode 1 is now closed end to end — gosd refuses instead of wiping, and atfs +asks for consent under a flag that means what it says. Mode 2 is untouched +by any of it: the volume mounts, atfs's write probe fails, and the device +restart-loops with no in-band exit. Nothing in gosd's mount path can see +that, because from its side the mount succeeded. Whatever fixes it will be +atfs-side and will need a way to reach a destructive reformat *after* a +successful mount — a different shape of ask from anything here, and worth +its own bean when it comes up rather than trailing this one. diff --git a/README.md b/README.md index 4e9fb8d..a681b8c 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,9 @@ atfs also builds as flashable SD-card images via [gosd](https://github.com/jphas Flash the image, then edit `gosd.toml` on the card's small `atfs-boot` partition (it mounts like any other FAT32 volume) — it ships pre-populated with atfs's own `[env]` section (see `packaging/env.toml`), commented explanations included, so setting `ATFS_OWNER_DID` is normally the only edit a new card needs. Blobs and this node's identity live on a separate `atfs-data` partition, mounted at the fixed `/data`; if that partition is missing or fails to mount, gosd falls back to an empty read-only volume instead, so a broken data partition fails loudly rather than silently losing writes. To expose the instance publicly, uncomment one of the commented-out `[ingress.*]` sections in `gosd.toml` — see "Serving" above. -Boards with onboard eMMC (NanoPi Zero2, Radxa Zero 3E) use it for storage automatically instead: atfsd formats and mounts a blank eMMC on first boot, then just mounts it on every boot after that. If the eMMC already holds something else, atfsd halts with instructions on the serial console rather than wiping it. Set `ATFS_DATA_DIR` to opt out and use the SD `/data` partition instead. To authorize wiping a non-blank eMMC, set `ATFS_FORMAT_DRIVES_IF_NOT_ATFS = "true"` in `gosd.toml`'s `[env]` — there's no shell to run `mkfs` by hand, so this is how an operator authorizes a wipe without needing a serial adapter to inspect the eMMC first. Remove it once the wipe has succeeded: it no longer covers only drives atfs doesn't recognize. gosd also refuses — rather than silently reformatting, which is the improvement — an eMMC that carries atfs's own label but has become unmountable, and atfs cannot tell the two refusals apart, so a value left in `gosd.toml` would authorize rebuilding atfs's own volume too, blobs and identity key included, with no further warning. +Boards with onboard eMMC (NanoPi Zero2, Radxa Zero 3E) use it for storage automatically instead: atfsd formats and mounts a blank eMMC on first boot, then just mounts it on every boot after that. If the eMMC already holds something else, atfsd halts with instructions on the serial console rather than wiping it. Set `ATFS_DATA_DIR` to opt out and use the SD `/data` partition instead. To authorize wiping a non-blank eMMC, set `ATFS_FORMAT_DRIVES_IF_NOT_ATFS = "true"` in `gosd.toml`'s `[env]` — there's no shell to run `mkfs` by hand, so this is how an operator authorizes a wipe without needing a serial adapter to inspect the eMMC first. The name says exactly what it does: it only ever formats a drive atfs doesn't already recognize as its own, so a value left in `gosd.toml` after a successful wipe is harmless — the freshly wiped eMMC is atfs's own from then on, and never gets reformatted by this flag again. + +There is a second, deliberately separate flag for the one case that *does* touch atfs's own data. If the eMMC carries atfs's own volume but has become unmountable, gosd refuses rather than silently reformatting it, and atfs halts. Setting `ATFS_REBUILD_UNHEALTHY_DRIVE = "true"` authorizes rebuilding it — which destroys every blob on the drive **and** the node's identity key, so it comes back with a new peer ID and its `dev.atfs.server` record (whose rkey is the old peer ID) has to be recreated by hand. Remove it once the rebuild has succeeded. The two are separate flags precisely so that authorizing the harmless one can never quietly authorize this one. There's no shell and no SSH on a gosd image: `atfsd` — the same binary as the container, built with gosd's `gosd` build tag — is the only binary `gosd-init` runs, restarted forever with backoff if it ever exits, and the serial console is the entire interface. That's where boot shows which release the card is running, this instance's peer ID, the `at://` URI for its config record, and whether uploads are enabled. diff --git a/go.mod b/go.mod index 8b1b0a8..783e414 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/ipfs/go-cid v0.6.2 github.com/ipfs/go-datastore v0.9.2 github.com/ipfs/go-ipld-format v0.6.4 - github.com/jphastings/gosd v0.3.1-0.20260810142209-2535d151ecd3 + github.com/jphastings/gosd v0.3.1-0.20260811063026-b26dc8e000a2 github.com/libp2p/go-libp2p v0.49.0 github.com/libp2p/go-libp2p-kad-dht v0.42.1 github.com/multiformats/go-multiaddr v0.16.1 diff --git a/go.sum b/go.sum index 60eeef8..1c76b55 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jphastings/gosd v0.3.1-0.20260810142209-2535d151ecd3 h1:R8icaXBB6aQGVaymaZgfkqhY5pEVe9VzbYnAJVKCVds= github.com/jphastings/gosd v0.3.1-0.20260810142209-2535d151ecd3/go.mod h1:wGxGRP3kquMxr0x0ddrh5CR6KHx8aAwetki0dA2JoDU= +github.com/jphastings/gosd v0.3.1-0.20260811063026-b26dc8e000a2 h1:dJv4/5WiQYzqig60grBsybTvlGETBDrJZd94JsNBORk= +github.com/jphastings/gosd v0.3.1-0.20260811063026-b26dc8e000a2/go.mod h1:wGxGRP3kquMxr0x0ddrh5CR6KHx8aAwetki0dA2JoDU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= diff --git a/internal/gosdboot/datadir.go b/internal/gosdboot/datadir.go index fbb588d..10922a7 100644 --- a/internal/gosdboot/datadir.go +++ b/internal/gosdboot/datadir.go @@ -48,19 +48,20 @@ const emmcMountpoint = "/emmc" // wipeEnvVar is the config-only escape hatch for a refused format: gosd // boards have no shell, so an operator can't just run mkfs by hand. // -// The name no longer tells the whole truth, and ATFS-c2ny owns fixing -// that. It was accurate when gosd only ever refused a format over content -// atfs didn't recognize; since gosd-psj0, gosd also refuses — rather than -// silently reformatting, which is the improvement — an eMMC carrying -// atfs's own label and filesystem that has become *unmountable*. Both -// arrive here as the same emmc.ErrRefusedFormat, so setting this to "true" -// now also authorizes rebuilding atfs's own volume, destroying the blobs -// and the identity key on it. An operator who set it once to adopt a drive -// and left it set would get that with no further warning. -// -// atfs cannot currently tell the two refusals apart without matching on -// error text; a distinct sentinel from gosd is the small change that would -// let this split into a separate, honestly-named rebuild flag. +// It authorizes exactly one thing: overwriting a drive atfs does not already +// recognize as its own — content under a foreign label, or a foreign +// filesystem under atfs's own label. It does NOT authorize rebuilding an +// eMMC that already carries atfs's own label and filesystem; that narrower +// case is emmc.ErrUnmountable, a distinct sentinel gosd wraps alongside +// ErrRefusedFormat specifically so the two can be told apart (see the +// ordering comment on ResolveDataDir's switch), and it is gated on the +// separate rebuildEnvVar (ATFS_REBUILD_UNHEALTHY_DRIVE) instead — see +// resolveUnhealthy. The split exists because the two authorizations differ +// in kind: this one only ever adopts a drive that isn't atfs's yet, so an +// operator who sets it once and leaves it set risks nothing on a later +// boot; authorizing a rebuild of atfs's own unhealthy volume destroys the +// blobs and the identity key on it, and earns its own flag and its own, +// starker warning rather than riding along on this one. // // It currently governs only the onboard eMMC (the one drive atfs manages // today), but is named generically so it can cover any future atfs-managed @@ -72,6 +73,21 @@ const emmcMountpoint = "/emmc" // is no label-matching variant to fall back on. const wipeEnvVar = "ATFS_FORMAT_DRIVES_IF_NOT_ATFS" +// rebuildEnvVar is wipeEnvVar's sibling for the narrower, starker case: +// atfs's own eMMC volume (right label, right filesystem) that will not +// mount. Kept as a separate flag rather than folded into wipeEnvVar because +// the two authorizations differ in kind — see wipeEnvVar's doc comment. +// Rebuilding destroys every blob on the drive AND the libp2p identity key, +// so the node comes back with a new peer ID and its dev.atfs.server record +// (rkey = the old peer ID) is orphaned and has to be recreated by the +// operator; resolveUnhealthy's messaging says so plainly both before and +// after the rebuild. +// +// The only accepted value is "true" (case-insensitive), mirroring +// wipeEnvVar exactly: any other non-empty value is a fatal config error +// naming this variable and its only valid value. +const rebuildEnvVar = "ATFS_REBUILD_UNHEALTHY_DRIVE" + // Mounter matches emmc.FormatAndMountWith's signature. It exists as a // seam so tests can fake the eMMC mount without real hardware. type Mounter func(label, mountpoint string, opts emmc.Options) <-chan emmc.Result @@ -104,12 +120,22 @@ const emmcFilesystem = emmc.EXT4 // - emmc.ErrNoEMMC is the normal, deterministic "this board has no // onboard eMMC" case: the SD data partition (sdDataDir) stays the // default, silently and without incident. -// - emmc.ErrRefusedFormat means the eMMC already holds other content. -// ResolveDataDir always attempts the non-destructive format-and-mount -// first, exactly as above; only on this refusal does it look at -// wipeEnvVar (ATFS_FORMAT_DRIVES_IF_NOT_ATFS) to see whether an -// operator has pre-authorized a wipe — see resolveRefusedFormat. Absent -// that authorization, or on any other error, it is fatal and returned +// - emmc.ErrUnmountable is the narrower case within ErrRefusedFormat: the +// eMMC already carries atfs's own label and filesystem but will not +// mount — its own volume, unhealthy, never someone else's content. gosd +// wraps both sentinels in the same error, so this case is matched +// before ErrRefusedFormat below (see the ordering comment on that +// switch arm) and is gated on the separate rebuildEnvVar +// (ATFS_REBUILD_UNHEALTHY_DRIVE) — see resolveUnhealthy — never on +// wipeEnvVar, since rebuilding it destroys the blobs and the identity +// key on it. +// - emmc.ErrRefusedFormat, once ErrUnmountable has been carved out above, +// always means the eMMC holds someone else's content. ResolveDataDir +// always attempts the non-destructive format-and-mount first, exactly +// as above; only on this refusal does it look at wipeEnvVar +// (ATFS_FORMAT_DRIVES_IF_NOT_ATFS) to see whether an operator has +// pre-authorized a wipe — see resolveRefusedFormat. Absent that +// authorization, or on any other error, it is fatal and returned // rather than falling back to sdDataDir. The libp2p identity key lives // under the data dir, so a silent fallback here would risk booting a // *different* peer ID than a previous (or future) boot that did mount @@ -133,6 +159,12 @@ func ResolveDataDir(mount Mounter) (string, error) { case errors.Is(res.Err, emmc.ErrNoEMMC): slog.Info("atfsd: no onboard eMMC on this board, using the SD data partition", "dir", sdDataDir) return sdDataDir, nil + // ErrUnmountable is a narrower case within ErrRefusedFormat — gosd wraps + // both sentinels in the same error — so it must be matched before the + // ErrRefusedFormat arm below, or that broader arm would swallow it and + // hand atfs's own unhealthy volume to the wrong flag. + case errors.Is(res.Err, emmc.ErrUnmountable): + return resolveUnhealthy(mount, res.Err) case errors.Is(res.Err, emmc.ErrRefusedFormat): return resolveRefusedFormat(mount, res.Err) default: @@ -173,3 +205,41 @@ func resolveRefusedFormat(mount Mounter, refusal error) (string, error) { "new_label", emmcLabel, "mountpoint", res.MountPoint, "device", res.BlockDevice) return emmcMountpoint, nil } + +// resolveUnhealthy handles emmc.ErrUnmountable: the narrower case within +// ErrRefusedFormat where the eMMC already carries atfs's own label and +// filesystem but will not mount. Unlike resolveRefusedFormat's foreign +// content, this is atfs's own data, unhealthy — rebuilding it destroys +// every blob on the drive AND the libp2p identity key, so the node comes +// back with a new peer ID and its dev.atfs.server record (rkey = the old +// peer ID) is orphaned and has to be recreated by the operator. Gated on +// its own config value in gosd.toml's [env] — rebuildEnvVar +// (ATFS_REBUILD_UNHEALTHY_DRIVE), read here and nowhere else — so a +// wipeEnvVar left set from an earlier adoption can never authorize this. +// +// "true" (case-insensitive) is the only accepted value: it retries +// destructive, which — because this whole path only runs on a refusal — +// only ever rebuilds a volume that is already ATFSDATA. Any other +// non-empty value is a fatal config error naming the variable and its only +// valid value; there is no label-matching variant to fall back on. +func resolveUnhealthy(mount Mounter, refusal error) (string, error) { + rebuild, ok := os.LookupEnv(rebuildEnvVar) + if !ok { + return "", fmt.Errorf("onboard eMMC carries atfs's own volume but it will not mount (%w) — this is atfs's own data, unhealthy, not someone else's content: rebuilding it destroys every blob on the drive AND the libp2p identity key, so the node comes back with a new peer ID and its dev.atfs.server record (rkey = the old peer ID) is orphaned and must be recreated by hand. Set ATFS_DATA_DIR to use the SD data partition instead and leave the eMMC untouched, or set %s = \"true\" to authorize the rebuild and accept that cost", refusal, rebuildEnvVar) + } + + if !strings.EqualFold(rebuild, "true") { + return "", fmt.Errorf("%s is set to %q, but %q is the only accepted value", rebuildEnvVar, rebuild, "true") + } + + slog.Warn("atfsd: ATFS_REBUILD_UNHEALTHY_DRIVE=true authorized a destructive rebuild of atfs's own unhealthy onboard eMMC — every blob on it and the libp2p identity key are about to be destroyed") + + res := <-mount(emmcLabel, emmcMountpoint, emmc.Options{Filesystem: emmcFilesystem, Destructive: true}) + if res.Err != nil { + return "", fmt.Errorf("onboard eMMC rebuild failed even though %s authorized it: %w", rebuildEnvVar, res.Err) + } + + slog.Warn("atfsd: onboard eMMC rebuilt with a new identity — the old dev.atfs.server record (rkey = the old peer ID) is now orphaned and must be recreated by hand; remove ATFS_REBUILD_UNHEALTHY_DRIVE from gosd.toml, it is no longer needed", + "new_label", emmcLabel, "mountpoint", res.MountPoint, "device", res.BlockDevice) + return emmcMountpoint, nil +} diff --git a/internal/gosdboot/datadir_test.go b/internal/gosdboot/datadir_test.go index 2bb390e..ece7c91 100644 --- a/internal/gosdboot/datadir_test.go +++ b/internal/gosdboot/datadir_test.go @@ -84,6 +84,24 @@ func refusedFormatErr(existingLabel string) error { return fmt.Errorf("the eMMC at /dev/mmcblk0 already holds ext4 labelled %q; %w it as %q without permission — pass destructive=true to wipe it", existingLabel, emmc.ErrRefusedFormat, emmcLabel) } +// unmountableErr builds an error shaped like the one gosd's blockmount +// package returns when the eMMC already carries atfs's own label and +// filesystem but will not mount — wrapping both emmc.ErrUnmountable and +// emmc.ErrRefusedFormat, exactly as emmc.ErrUnmountable's doc comment says +// gosd does, so a plain errors.Is(err, emmc.ErrRefusedFormat) check alone +// cannot tell the two refusals apart. +func unmountableErr() error { + return fmt.Errorf("the eMMC at /dev/mmcblk0 carries %q as expected but would not mount: %w (%w)", emmcLabel, emmc.ErrUnmountable, emmc.ErrRefusedFormat) +} + +// clearATFSRebuildUnhealthyDrive ensures ATFS_REBUILD_UNHEALTHY_DRIVE is +// unset for tests that don't exercise it, so an ambient value in the +// surrounding environment can never leak into their behavior. +func clearATFSRebuildUnhealthyDrive(t *testing.T) { + t.Helper() + clearEnv(t, "ATFS_REBUILD_UNHEALTHY_DRIVE") +} + func TestResolveDataDir_ExplicitATFSDataDirSkipsEMMCProbe(t *testing.T) { t.Setenv("ATFS_DATA_DIR", "/wherever/the/operator/wants") @@ -236,3 +254,96 @@ func TestResolveDataDir_WipeEnvNonTrueValueFailsWithoutDestructiveCall(t *testin t.Errorf("mount called %d times, want 1 — a non-true value must never trigger the destructive retry", len(*calls)) } } + +func TestResolveDataDir_UnmountableRefusalWithNoFlagIsFatal(t *testing.T) { + clearATFSDataDir(t) + clearATFSFormatDrivesIfNotATFS(t) + clearATFSRebuildUnhealthyDrive(t) + + mount := fakeMounter(emmc.Result{Err: unmountableErr()}) + + _, err := ResolveDataDir(mount) + if err == nil { + t.Fatal("ResolveDataDir returned nil error for an unmountable own-volume refusal; want a fatal error, not a silent fallback") + } + if !strings.Contains(err.Error(), "ATFS_REBUILD_UNHEALTHY_DRIVE") { + t.Errorf("ResolveDataDir error = %q, want it to name ATFS_REBUILD_UNHEALTHY_DRIVE as the way to authorize a rebuild", err.Error()) + } +} + +func TestResolveDataDir_RebuildEnvTrueRetriesDestructiveThenSucceeds(t *testing.T) { + clearATFSDataDir(t) + clearATFSFormatDrivesIfNotATFS(t) + t.Setenv("ATFS_REBUILD_UNHEALTHY_DRIVE", "true") + + calls, mount := sequencedMounter( + emmc.Result{Err: unmountableErr()}, + emmc.Result{MountPoint: emmcMountpoint, BlockDevice: "/dev/mmcblk0"}, + ) + + got, err := ResolveDataDir(mount) + if err != nil { + t.Fatalf("ResolveDataDir: %v", err) + } + if got != emmcMountpoint { + t.Errorf("ResolveDataDir = %q, want %q", got, emmcMountpoint) + } + if len(*calls) != 2 { + t.Fatalf("mount called %d times, want 2 (non-destructive probe, then destructive rebuild)", len(*calls)) + } + if (*calls)[0].opts.Destructive { + t.Error("first mount call was destructive; the non-destructive probe must always happen first") + } + if !(*calls)[1].opts.Destructive { + t.Error("second mount call was not destructive; ATFS_REBUILD_UNHEALTHY_DRIVE=true should retry with destructive=true") + } +} + +// TestResolveDataDir_WipeEnvAloneDoesNotAuthorizeRebuild pins the whole +// point of the two-flag split: an operator who left +// ATFS_FORMAT_DRIVES_IF_NOT_ATFS set after adopting a drive must not have it +// silently authorize destroying atfs's own unhealthy volume too. +func TestResolveDataDir_WipeEnvAloneDoesNotAuthorizeRebuild(t *testing.T) { + clearATFSDataDir(t) + clearATFSRebuildUnhealthyDrive(t) + t.Setenv("ATFS_FORMAT_DRIVES_IF_NOT_ATFS", "true") + + calls, mount := sequencedMounter(emmc.Result{Err: unmountableErr()}) + + _, err := ResolveDataDir(mount) + if err == nil { + t.Fatal("ResolveDataDir returned nil error for an unmountable own-volume refusal with only ATFS_FORMAT_DRIVES_IF_NOT_ATFS set; want a fatal error — that flag must never authorize rebuilding atfs's own volume") + } + if !strings.Contains(err.Error(), "ATFS_REBUILD_UNHEALTHY_DRIVE") { + t.Errorf("ResolveDataDir error = %q, want it to name ATFS_REBUILD_UNHEALTHY_DRIVE", err.Error()) + } + if len(*calls) != 1 { + t.Errorf("mount called %d times, want 1 — ATFS_FORMAT_DRIVES_IF_NOT_ATFS must never trigger a destructive retry for an unmountable own-volume", len(*calls)) + } +} + +// TestResolveDataDir_OrdinaryRefusalUnaffectedByRebuildFlag confirms the +// split cuts both ways: an ordinary foreign-content refusal still goes +// through ATFS_FORMAT_DRIVES_IF_NOT_ATFS exactly as before, regardless of +// ATFS_REBUILD_UNHEALTHY_DRIVE. +func TestResolveDataDir_OrdinaryRefusalUnaffectedByRebuildFlag(t *testing.T) { + clearATFSDataDir(t) + clearATFSRebuildUnhealthyDrive(t) + t.Setenv("ATFS_FORMAT_DRIVES_IF_NOT_ATFS", "true") + + calls, mount := sequencedMounter( + emmc.Result{Err: refusedFormatErr("WEBSITE")}, + emmc.Result{MountPoint: emmcMountpoint, BlockDevice: "/dev/mmcblk0"}, + ) + + got, err := ResolveDataDir(mount) + if err != nil { + t.Fatalf("ResolveDataDir: %v", err) + } + if got != emmcMountpoint { + t.Errorf("ResolveDataDir = %q, want %q", got, emmcMountpoint) + } + if len(*calls) != 2 || !(*calls)[1].opts.Destructive { + t.Errorf("ATFS_FORMAT_DRIVES_IF_NOT_ATFS=true should still authorize wiping foreign content with ATFS_REBUILD_UNHEALTHY_DRIVE unset; calls=%+v", *calls) + } +}