fix: third audit — a cross-tree dataset was omitted silently, and the block tests passed on comments

D1, the only cardinal-rule violation left. plan_staging scopes by dataset NAME, which
is right (a dataset with no mountpoint cannot be scoped by path). But ZFS lets any
dataset mount anywhere, so one from a DIFFERENT tree can sit inside the backup path:

    Tank/photos   mountpoint=/mnt/Tap/apps/photos

It holds data inside the path, and `zfs snapshot -r Tap@...` does NOT cover it —
recursion follows the dataset tree, not the directory tree. It fell out of the name
filter and vanished: not staged, not in `skipped`, no error. The backup reported
SUCCESS with that data missing. Stock has the same blind spot but refuses the nested
config outright; we are the ones relaxing that guard, so the hole is ours. It now
raises.

The test suite was the real weakness. apply.sh's injected blocks carry the
highest-consequence logic in the project — the run_in_thread hop, the flavour
selection, the finally-teardown, the re-raise — and were guarded only by substring
greps. Two of them passed on COMMENTS: `assert "raise" in block` was satisfied by a
comment reading "a cleanup that raises...", and `assert "cleanup_task" in block` by
"cleanup_task gets logger=None". Deleting the actual re-raise (restic then backs up the
UN-STAGED path — the silently-empty backup this module exists to prevent) and deleting
the actual cleanup call from the finally (~250 orphans per run) both left the suite
green. They are asserted structurally now, against the parsed block.

Eleven regressions the audit found surviving now fail the suite, including: a swallowed
staging failure, a missing teardown, an inverted flavour mapping, blocking work back on
the asyncio event loop, the host's deleted get_dataset_recursive, query_filesystems
quietly preferring the filtered middleware query, and a re-frozen `runner`/`sleep`/
`mounts_file` default (which would silently re-arm 19 tests reading the real mount
table on the NAS).

Also: _read_sidecar conflated "no sidecar" with "cannot read the sidecar", so
cleanup_task took the empty branch and UNLINKED the only record of a tree it could not
read. mounted_snapshots returned an empty set on error, silently switching off the GC's
protection for snapshots a concurrent run is using. Both raise now.

Verified on TrueNAS 26.0.0-BETA.1: zvol-orphan case 0 orphans, 292-dataset backup
0 orphans / 0 leaked mounts, byte-identical restore of a 4-deep child dataset.
This commit is contained in:
2026-07-14 00:38:45 +00:00
parent 0fea5c40bd
commit 8a41d7d7ef
3 changed files with 422 additions and 15 deletions
+54 -3
View File
@@ -452,16 +452,30 @@ def _write_sidecar(staging_root: str, snapshots, logger=None) -> None:
)
def _read_sidecar(staging_root: str):
def _read_sidecar(staging_root: str, logger=None):
"""Every snapshot tree a previous run recorded here. [] if none.
Tolerates the old single-line format, which is just a one-element list.
"There is no sidecar" and "I could not READ the sidecar" are different facts, and
conflating them is dangerous: `cleanup_task` reads an empty list as "nothing was
ever staged" and then REMOVES the sidecar -- destroying the only record of a tree
it could not read. FileNotFoundError is the ordinary case and stays quiet; any
other OSError is reported, and re-raised so no caller mistakes it for "empty".
"""
try:
with open(sidecar_for(staging_root), encoding="utf-8") as fh:
return [ln.strip() for ln in fh if ln.strip()]
except OSError:
return []
except FileNotFoundError:
return [] # genuinely nothing recorded
except OSError as e:
if logger:
logger.error(
"truecloud-patch: could not READ the snapshot record %s (%r). Not "
"touching it -- it may name snapshots nothing else can find.",
sidecar_for(staging_root), e,
)
raise
def _remove_sidecar(staging_root: str) -> None:
@@ -656,6 +670,43 @@ def plan_staging(base_dataset, base_mountpoint, path, snapshot_name, datasets,
mounts.append((src, os.path.join(staging_root, os.path.relpath(mp, path))))
# ── datasets INSIDE the path but OUTSIDE the snapshot's tree ─────────────
#
# The loop above scopes by dataset NAME, which is right: a dataset with no
# mountpoint cannot be scoped by path at all. But ZFS lets any dataset mount
# anywhere, so a dataset from a DIFFERENT tree -- even a different pool -- can
# sit inside the backup path:
#
# Tank/photos mountpoint=/mnt/Tap/apps/photos
#
# It holds data inside the path, so its absence is a hole in the backup. And
# `zfs snapshot -r Tap@...` does NOT cover it, because recursion follows the
# DATASET tree, not the directory tree -- so there is no snapshot of it to
# stage, and no way to capture it consistently with the rest.
#
# Before this check it was neither staged, nor reported in `skipped`, nor raised:
# it simply fell out of the name filter and vanished. The backup reported
# SUCCESS with that data missing, which is the precise failure this module
# exists to prevent. Stock has the same blind spot, but stock also REFUSES the
# nested config outright -- we are the ones relaxing that guard, so the hole is
# ours to close.
foreign = sorted(
ds.get("name", "")
for ds in datasets
if (mp := ds.get("properties", {}).get("mountpoint", {}).get("value", ""))
and mp.startswith(path_prefix)
and not ds.get("name", "").startswith(ds_prefix)
and ds.get("name", "") != base_dataset
)
if foreign:
raise StagingError(
"dataset(s) outside " + repr(base_dataset) + " are mounted inside the "
"backup path and cannot be captured by its recursive snapshot: "
+ ", ".join(repr(f) for f in foreign)
+ ". Refusing to back up an incomplete tree -- move them, or back up "
"their own dataset separately."
)
# Parents before children, so each mountpoint exists before we mount onto it.
mounts.sort(key=lambda m: _depth(m[1]))
return mounts, skipped