Files
rsync/testsuite/operator-path-temp-dir_test.py
Andrew Tridgell de38616b25 testsuite: detect an operator-path escape without sub-second mtimes
operator-path-temp-dir and operator-path-partial-dir decided whether a
symlink had been followed by sampling the target directory's
st_mtime_ns, sleeping 10ms, and looking for a change.  The temp file is
renamed away, so an mtime bump was the only trace left.

On a filesystem whose timestamps have 1-second granularity -- HFS+, and
it is not alone -- a change within the same second is invisible.  The
delta is zero, the test concludes the symlink was not followed, and
reports the operator's OWN euid-owned symlink as refused when it was
followed correctly.  Both fail that way on HFS+ while passing on APFS,
and operator-path-partial-dir is one of the failures Roland Kletzing
reported on macOS.

Pin the directory's mtime to a fixed past epoch instead, read back what
the filesystem actually stored, and ask afterwards whether it still
holds -- reading back because a filesystem may clamp or round the value,
and comparing against the requested epoch would then read an unfollowed
symlink as followed.  temp-dir-symlink-injection already works this way.

This is not proof against every clock: a directory whose mtime lands
exactly on the stored sentinel would still read as unfollowed.  That
needs the host clock set to 2001 or a deliberate restore, where the old
10ms delta failed on any coarse-granularity filesystem.

Verified in both directions by running as root, where the matrix also
exercises the cross-uid cells: a followed symlink moves the mtime off
the sentinel, a refused one leaves it.
2026-08-01 12:12:21 +10:00

44 lines
2.0 KiB
Python

#!/usr/bin/env python3
# --temp-dir symlink policy matrix. A followed --temp-dir symlink makes the
# receiver create its scratch temp file in an out-of-tree directory (the data is
# written there, then renamed to dest). The temp is renamed away, so we detect
# the escape by the target directory's mtime advancing (a file was created in
# it). The ownership walk must follow only uid0/euid-owned symlinks; a relative
# --temp-dir anchors at the cwd. --insecure-links is the local opt-out.
import os
import subprocess
from rsyncfns import rsync_argv, run_symlink_matrix, plant_operator_symlink
PINNED = 1000000000 # 2001-09-09; any later mtime means it was touched
def case(ctx):
src = ctx.base / 'src'
dest = ctx.base / 'dest'
src.mkdir()
dest.mkdir()
(src / 'f0').write_text("PAYLOAD-DATA\n")
# A relative --temp-dir is resolved by the receiver, whose cwd is the
# destination directory -- so a relative plant anchors at dest, not the cwd.
opt, escape = plant_operator_symlink(ctx, dest)
escape.mkdir(parents=True, exist_ok=True) # --temp-dir must exist
# Pin the mtime far in the past rather than sampling it and looking for a
# sub-second delta: HFS+ (and any filesystem with 1-second timestamps)
# cannot show a change that happens within the same second, so a delta test
# reads "not followed" for a symlink that WAS followed. Against a pinned
# epoch, any update at all is visible at any resolution.
os.utime(escape, (PINNED, PINNED))
pinned = escape.stat().st_mtime # what the fs actually stored
extra = ['--insecure-links'] if ctx.insecure else []
subprocess.run(
rsync_argv('-a', f'--temp-dir={opt}', *extra, 'src/', 'dest/'),
cwd=str(ctx.base), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Followed == the receiver created its temp in the out-of-tree dir.
return escape.stat().st_mtime != pinned
run_symlink_matrix('--temp-dir', case)
print("--temp-dir symlink policy matrix: enforced")