Repository navigation
Conversation
scan_case_dir() walks the case directory with followlinks=True and keeps no record of where it has been, so a single ancestor-pointing directory symlink inside evidence/ — `ln -s ..`, or a link back at the case dir — makes the walk re-enter the same subtree until the kernel's symlink limit stops it, 41 times over. The tree under evidence/ is copied verbatim off collected hosts, and the project's own workflow puts symlinks there, so this is reachable from a plain `vhir backup <dest>` with no flags. On a 2000-file evidence tree one such link turns 2001 entries into 82001; with --include-evidence those are 82001 copies and SHA-256 hashes written to 41 distinct manifest paths. Three such links in one directory did not finish at all (killed after 120s). os.walk reports no error for any of it — the ELOOP is swallowed because the failing entry's is_dir() returns False — so the operator gets no signal. The walk now carries the (st_dev, st_ino) of every directory on the path from case_dir down to the current root, and prunes a subdirectory only when it resolves to one of those — an edge that can only ever re-list a directory already on the current path. Nothing else is pruned: symlinked evidence trees are still followed and still scanned (the documented `ln -s <resolved> evidence/<name>` layout is unaffected), and a non-looping duplicate path such as `evidence/latest -> host1` is still backed up under both names, as before. Directories that cannot be stat'ed are left in place for os.walk to skip exactly as it did before. Measured on a 2001-file case tree, best of 5, python 3.12: one `evidence/host1/loop -> ..` 73.409s -> 0.263s (279x), 82001 -> 2001 entries three such links no termination (killed at 120s) -> 0.265s link-free tree (control) 0.212s -> 0.222s Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
This PR is a draft because it needs a decision on whether to accept single-path backup semantics. That decision is written up on its own, with the options laid out, in #49 — probably the better place to discuss it than this diff. |
elhoim
marked this pull request as ready for review
October 9, 2026 20:03
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BLUF
evidence/host1/loop -> ..symlink takes 73.4s -> 0.263s (279x) inscan_case_dir, and yields 82001 -> 2001 entries; with--include-evidencethose 80000 surplus entries are surplus file copies and SHA-256 hashes written to distinct manifest paths.vhir backup <dest>with no flags — the walk at backup.py:486 is unconditional.evidence/, which is copied verbatim off collected hosts, plus the project's own documentedln -s <resolved> case_dir/evidence/<name>workflow.os.walkswallows the ELOOP, so the operator gets no warning at all.(st_dev, st_ino)of every directory on the path fromcase_dirto the current root and prune a subdirectory only when it resolves to one of them. 28 lines, local toscan_case_dir, stdlib only.--include-evidencewhen evidence/ and extractions/ overlap, and makes backup contents depend on scandir order. So sibling-link 2x duplication is preserved and complexity is O(F_acyclic_reachable), not O(F_distinct).followlinks=Falseregression cannot slip in.main@b2324ac(2026-10-05): the bug is still present — this PR's 3 new loop tests still fail onmain— e.g. two mutually reachable symlinks make the walk visit 146 entries instead of 7. The branch now hasmainmerged in.Priority: HIGH
The hot construct
src/vhir_cli/commands/backup.py:486—scan_case_dir()walks the case tree following symlinks, and the only thing it ever does todirsis the skip-name filter:There is no record of which directories have already been entered. A directory symlink that points back up the tree —
ln -s .. evidence/host1/loop, or a link back at the case directory itself — therefore gets descended again, and again, until the kernel'sMAXSYMLINKSlimit (40) refuses the next resolution: the subtree is listed 41 times.os.walkreports nothing: the entry that finally failsELOOPhasis_dir()returnFalse, soonerrornever fires and the operator sees no warning.This is not a hypothetical shape for this tool.
evidence/holds trees copied verbatim off compromised hosts during triage collection,evidence.pyinstructs examiners toln -s <resolved> <case_dir>/evidence/<name>, andprune_manifests.pyconfirmscase/evidence/is normally itself a symlink to/mnt/evidence/. The walk is unconditional — plainvhir backup <destination>with no flags hits it. With--include-evidenceor--all, every duplicate entry becomes a realshutil.copy2and a realsha256_filepass writing the same bytes to a distinct manifest path.Two or more mutually reachable links compound: measured, three ancestor links in one directory never finished.
The fix
Carry the
(st_dev, st_ino)of every directory on the path fromcase_dirdown to the current root, keyed by the pathos.walkyields, and prune a subdirectory only when it resolves to one of those:chainsis popped as each root is consumed, so it only ever holds the pending frontier, not one entry per directory visited. Cost is one extrastat()per directory (not per file). Stdlib only, no new module, no new dependency, entirely local toscan_case_dir.Pruning only the ancestor chain — rather than a global inode set — catches every looping shape:
..,., a link straight back at the case dir, mutually reachable sideways pairs (evidence/ext -> /mnt/x,/mnt/x/back -> case/evidence), andln -s /(which now walks the root filesystem once rather than 41 times).Measurements
2001-file case tree (20 dirs x 100 files +
notes.md),scan_case_dironly, best of 5 after a warm pass, Python 3.12:evidence/host1/loop -> ..The entry counts matter as much as the seconds: with
--include-evidence, 82001 entries are 82001shutil.copy2calls and 82001 SHA-256 passes over the same 2001 files, landing in 41 sets of manifest paths. Scaled to a real 1M-file evidence tree, one such link is ~40M stats and ~40M accumulated tuples.Behaviour preservation
ruff check .andruff format --check .clean.tests/test_backup_scan.py(there were no backup tests):test_parent_symlink_scans_subtree_once— red on original:assert 246 == 6test_symlink_to_case_root_scans_case_once— red on original:assert 286 == 6test_mutually_reachable_symlinks_terminate— red on original:assert 146 == 7test_evidence_symlink_target_is_scanned— passes on both; pins that the documentedln -s <resolved> evidence/<name>layout is still followed and scanned. This is the guard against "fixing" this withfollowlinks=False, which would silently drop evidence from backups.test_sibling_symlink_is_still_scanned— passes on both; pins that a non-looping duplicate path (evidence/latest -> host1) is still backed up under both names.Trade-off the maintainer should decide on
I deviated from the obvious fix (a global
seen: set[(st_dev, st_ino)]) on purpose, and the PR is weaker in one dimension because of it:evidence -> /mnt/evidenceandextractions -> /mnt/evidence/extractions, the overlapping subtree is pruned from whichever list is reached second — so--include-evidencewithout--include-extractionswould silently omit those files from the backup. Evidence loss beats a constant factor.os.scandirorder, i.e. the same case backed up twice produces different archive contents.So: sibling-link duplication (2x/4x) is preserved, and the complexity here is
O(F_acyclic_reachable)with guaranteed termination, notO(F_distinct). If you would rather have single-path semantics, the global set is a two-line change on the samedirs[:]line — happy to take it, with those two caveats understood.Assumption, stated plainly: this trusts
(st_dev, st_ino)to be unique among directories on one root-to-leaf path, which every POSIX filesystem guarantees. A spurious collision would have to be on the ancestor chain, not anywhere in the tree — which is why this is safer than a global set on filesystems with weak inode semantics.Independent review notes
This change was reviewed adversarially by a second pass that re-ran the benchmarks. Where that review contradicted or qualified the account above, it is recorded here rather than quietly edited out:
os.stat()per directory is invisible. The reviewer rebuilt the same 2001 files as 2000 dirs x 1 file:+10% to +40% (0.172-0.190 s before, 0.183-0.285 s after). Directory-heavy evidence trees — registry
hive dumps, per-PID captures — pay that.
seeninode set, because withevidence -> /mnt/evidenceandextractions -> /mnt/evidence/extractionsit would silently drop theoverlapping subtree from whichever list is reached second. The ancestor-only guard is the conservative
choice; the global set reaches the audit's O(F_distinct) but changes which files get backed up.
Deliberately not done here
If the maintainer decides single-path semantics are acceptable for backups (i.e. each physical directory backed up under exactly one path), a global
seen: set[tuple[int, int]]on the samedirs[:]line reaches the finding's O(F_distinct) and also kills the sibling 2x/4x duplication — but only with the two caveats above understood: overlapping evidence/extractions trees can lose files when only one category is included, and which path survives becomes scandir-order dependent. That would want an explicit decision and probably a note in the backup output listing the deduped paths.Separately, the same
dirs[:]line is the subject of finding #2 (depth-0 prune of excluded top-level categories); the two fixes are independent and compose — neither substitutes for the other — but whichever lands second will need a trivial rebase of that one hunk. Deliberately did not build a shared walk helper: it would couple these PRs.CI status
The checks on this PR will show
action_required: GitHub gates workflow runs on pull requests fromfirst-time fork contributors until a maintainer approves them. They need an Approve and run click.
The CI job was reproduced locally after merging
main@b2324ac(761 tests onmain), using the same steps as.github/workflows/ci.yml:pip install -e ".[dev]" && pytest tests/— Python 3.10pip install -e ".[dev]" && pytest tests/— Python 3.11pip install -e ".[dev]" && pytest tests/— Python 3.12ruff check .+ruff format --check .(latest ruff, unpinned as in CI)🤖 Generated with Claude Code