util: restore ".." collapsing in clean_fname()#1029
Conversation
5f1ed67 to
954959d
Compare
Commit 9e08984 ("util: fixed issue in clean_fname()") fixed a genuine read-before-buffer in the ".." back-up loop, but the replacement loop leaves 's' in a different place than the one it replaced. The old loop while (s > limit && *--s != '/') {} pre-decremented, so it stopped with 's' pointing *at* the preceding '/'. The new loop while (s > limit && s[-1] != '/') s--; stops with 's' pointing at the *start* of the preceding component, one byte past that '/'. The surrounding test and assignment were carried over unchanged, so they are now off by one: if (s != t - 1 && (s <= name || *s == '/')) { t = (s == name) ? name : s + 1; '*s' is a component character and is essentially never '/', so the collapse only still happens via the "s == name" case -- a single leading component with no slash before it. Every other form silently stops collapsing: a/../b -> b (still worked) /a/../b -> /a/../b (should be /b) a/b/../c -> a/b/../c (should be a/c) /usr/local/../bin -> /usr/local/../bin (should be /usr/bin) x/y/../../z -> x/y/../../z (should be z) Adjust the two spots for where the new loop actually stops: check the byte before 's' for the '/' (or that we reached the start of the name), and set 't = s'. This keeps the underflow fix -- 's[-1]' is only read when 's' is past 'name', and the "s == name" test short-circuits -- while restoring the long-standing collapsing behaviour. clean_fname(..., CFN_COLLAPSE_DOT_DOT_DIRS) is rsync's canonical path normalizer, used for filter and merge-file names and for the --backup-dir, --partial-dir and --temp-dir options, so paths containing an interior ".." were left un-normalized. The CFN_REFUSE_DOT_DOT_DIRS path returns before this code and is unaffected, as is sanitize_path(). testsuite/clean-fname-collapse_test.py exercises the collapse through a merge-filter filename: the filter file is at "a/c" but is named as "a/none/../c" with no "a/none" directory, so it can only be opened if ".." is collapsed. It fails before this change and passes after.
954959d to
a70997e
Compare
Mutation testing on clean_fname() showed the test only pinned one of the three shapes the ".." logic has to get right. Fifteen small edits to the function were applied and the suite re-run; ten survived, and classifying them against a reference model of the intended semantics showed six of those changed behaviour and were simply not being detected. Notably, restoring the pre-fix "limit = name - 1" initialiser -- i.e. a partial revert of this very fix -- still passed, as did turning the "s == name" test into "s != name", which both stops the collapse and reintroduces the out-of-bounds read that 9e08984 set out to remove. The gap was that "a/none/../c" only exercises a ".." that eats a component with a '/' in front of it. Two more merge-filter names cover the rest: "zzz/../f1" eats the leading component, where the back-up reaches the start of the name and there is no earlier '/' to find, and "../../updir" (run from two directories down) has nothing to collapse into and must be preserved. Each name resolves only if clean_fname() handles that shape exactly right; otherwise the merge file cannot be opened, or a different file is opened and *.skip is not filtered out. That takes the kill rate on behaviour-changing mutants from 5/11 to 10/11. Of the five that still survive, four are equivalent mutants -- the reference model confirms they alter no output -- so they cannot be killed by any test. The remaining one (advancing the scan by one byte instead of two) only shows up on a name ending in "..", which cannot be expressed as a merge-filter file, so it is left uncovered rather than contorting the test to reach it.
|
I ran mutation testing over this area to check the fix is actually pinned down, and it found a genuine weakness — in the test, not the code. Pushed Method. Fifteen small edits to What survived that shouldn't have. Six of the ten changed behaviour and nothing caught them, including two that matter a lot here:
Why. Fix. Two more merge-filter names in the same test, covering the other two shapes:
Each name resolves only if that shape is handled exactly right; otherwise the merge file can't be opened, or a different file is opened and Kill rate on behaviour-changing mutants: 5/11 → 10/11. Of the five still surviving, four are equivalent mutants (the model confirms they alter no output), so no test can kill them. The last one — advancing the scan by one byte instead of two — only shows on a name ending in Separately, on the OpenBSD red: that job timed out after 300s in |
Problem
Commit 9e08984 ("util: fixed issue in clean_fname()") fixed a genuine read-before-buffer in the
".."back-up loop, but the replacement loop stops in a different place than the one it replaced, and the surrounding test/assignment were carried over unchanged.The old loop pre-decremented, so it stopped with
spointing at the preceding/:The new loop stops with
spointing at the start of the preceding component, one byte past that/:But the follow-up is unchanged, so it is now off by one:
*sis a component character and is essentially never/, so the collapse now only happens via thes == namecase — a single leading component with no slash before it. Every other form silently stopped collapsing:a/../bbb(still worked)/a/../b/a/../b/ba/b/../ca/b/../ca/c/usr/local/../bin/usr/local/../bin/usr/binx/y/../../zx/y/../../zzone/two/three/../../fourone/two/three/../../fourone/fourImpact
clean_fname(..., CFN_COLLAPSE_DOT_DOT_DIRS)is rsync's canonical path normalizer — used for filter and merge-file names and for the--backup-dir,--partial-dirand--temp-diroptions — so any such path containing an interior..is left un-normalized.User-visible example (a merge filter that can only be opened if
..is collapsed):Before 9e08984 this collapsed to
a/cand worked.Fix
Adjust the two spots for where the new loop actually stops: test the byte before
sfor the/(or that we reached the start of the name), and sett = s.This keeps the underflow fix —
s[-1]is only read whensis pastname, and thes == nametest short-circuits — while restoring the long-standing behaviour.The
CFN_REFUSE_DOT_DOT_DIRSpath returns before this code and is unaffected, as issanitize_path()(the separate daemon-side..handler), so this is a path-normalization/file-selection fix, not a security change.Test
testsuite/clean-fname-collapse_test.pydrives the collapse through a merge-filter filename: the filter file lives ata/cbut is nameda/none/../cwith noa/nonedirectory, so it can only be opened if..is collapsed. It also asserts the filter was actually applied. It fails before this change and passes after; full testsuite otherwise unchanged (106 passed, 0 failed).