mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-14 14:18:23 -04:00
Which symbol names this test's LD_PRELOAD hook exports is decided by the
compiler that builds it, and which names rsync imports is decided by
configure -- and the two do not have to agree.
Where off_t is not already 64 bits, configure's AC_SYS_LARGEFILE adds
-D_FILE_OFFSET_BITS=64 (i386 and alpha in Debian), so glibc redirects every
open()/openat()/fstatat() call in rsync to open64(), openat64() and
fstatat64(). The hook is compiled by a bare "cc", so on those architectures
it defines only the unsuffixed names: the receiver's opens never reach it, no
EACCES is injected, the marker the positive control looks for is never
written, and the test fails with
positive control failed: receiver did not open the existing partial file
with O_CREAT (rc=0, output='')
It happens to work on Debian's 64-bit time_t ports (armhf, hppa, powerpc,
...) only by luck: their gcc predefines -D_FILE_OFFSET_BITS=64 -D_TIME_BITS=64,
so glibc's __REDIRECT renames the hook's own DEFINITIONS as well and it ends
up exporting exactly the *64 names rsync imports.
Define both spellings explicitly so the hook interposes whichever set the
rsync under test was linked against, and #undef the two macros at the top of
the hook so that renaming cannot happen -- otherwise, on precisely those ports
whose compiler predefines them, open() would be emitted as open64() and
collide with the explicit wrapper ("symbol `open64' is already defined"),
leaving the hook unbuildable and the test skipped.
The new pointers are resolved through hook_resolve(), so the existing
nested-dlsym recursion guard covers them as well.
Failing build logs:
i386 https://buildd.debian.org/status/fetch.php?pkg=rsync&arch=i386&ver=3.5.0%2Bds1-1&stamp=1786938290&raw=0
alpha https://buildd.debian.org/status/fetch.php?pkg=rsync&arch=alpha&ver=3.5.0%2Bds1-1&stamp=1786973432&raw=0
513 lines
18 KiB
Python
513 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""PoC: EACCES recovery must retain partial-dir ownership policy (Linux).
|
|
|
|
Linux twin of partial-protected-regular-retry-policy_test.py, which runs only
|
|
under dyld interposing. It covers the recovery arm that test cannot reach:
|
|
the fs.protected_regular compatibility retry, i.e. the "#ifdef linux" arm of
|
|
recv_files(), which is not compiled on Darwin. Between them the two tests
|
|
cover both arms (Darwin's first EACCES goes straight to the generic
|
|
read-only-file chmod-and-reopen recovery, there being no Linux arm there).
|
|
|
|
The one-inplace partial leaf is first opened through the operator ownership
|
|
walk. The hook models an EACCES from that O_CREAT open and swaps the partial
|
|
directory for a symlink in the exact recovery window. A retry that drops to
|
|
the ordinary resolver follows the new parent with no ownership check and
|
|
writes the remote payload into a different file; one that keeps the walk
|
|
refuses the foreign-owned symlink.
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
|
|
from rsyncfns import (
|
|
SCRATCHDIR, forced_protocol, rmtree, rsync_argv, test_fail, test_skipped,
|
|
)
|
|
|
|
|
|
if platform.system() != 'Linux':
|
|
test_skipped('LD_PRELOAD partial EACCES recovery hook is Linux-only')
|
|
|
|
# one_inplace staging needs inplace_partial, which is negotiated through the
|
|
# protocol-30 CF_INPLACE_PARTIAL_DIR capability flag. Below that the receiver
|
|
# uses the ordinary tmpfile path and the recovery under test never runs.
|
|
_proto = forced_protocol()
|
|
if _proto is not None and _proto < 30:
|
|
test_skipped(f'one-inplace partial staging needs protocol >= 30 (forced {_proto})')
|
|
|
|
hook_code = r'''
|
|
#define _GNU_SOURCE
|
|
/* Build the hook itself WITHOUT large-file redirection, whatever the compiler
|
|
* defaults to. Debian's armhf/hppa/powerpc gcc predefines
|
|
* -D_FILE_OFFSET_BITS=64 -D_TIME_BITS=64 (check with "gcc -v -E -"), and under
|
|
* those macros glibc's __REDIRECT renames the DEFINITIONS below -- open()
|
|
* becomes open64(), fstatat() becomes __fstatat64_time64() -- which then
|
|
* collide with the explicit large-file wrappers further down ("symbol `open64'
|
|
* is already defined"). Undefining them here keeps each name declared exactly
|
|
* once, so the hook always exports both spellings and interposes whichever set
|
|
* the rsync under test was linked against. */
|
|
#undef _FILE_OFFSET_BITS
|
|
#undef _TIME_BITS
|
|
#include <sys/stat.h>
|
|
#include <sys/syscall.h>
|
|
#include <sys/types.h>
|
|
#include <dlfcn.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <limits.h>
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
static int swapped;
|
|
|
|
static int (*real_open)(const char *, int, ...);
|
|
static int (*real_openat)(int, const char *, int, ...);
|
|
static int (*real_fstatat)(int, const char *, struct stat *, int);
|
|
static int (*real_fxstatat)(int, int, const char *, struct stat *, int);
|
|
static int (*real_fstatat64)(int, const char *, struct stat64 *, int);
|
|
static int (*real_fxstatat64)(int, int, const char *, struct stat64 *, int);
|
|
static int (*real_fstatat64_time64)(int, const char *, struct stat64 *, int);
|
|
|
|
/* Resolve on demand rather than trusting our constructor to have run. A
|
|
* preloaded open() interposes for the whole process the moment the loader maps
|
|
* us, including calls made from OTHER libraries' constructors -- and the order
|
|
* constructors run between unrelated shared objects is unspecified. On
|
|
* AlmaLinux 8, OPENSSL_init_library() calls open() from its constructor before
|
|
* ours runs, so a plain "resolved in the constructor" pointer is still NULL and
|
|
* the process dies with SIGSEGV inside the loader. */
|
|
/* If dlsym() itself reaches an interposed function, the nested wrapper must
|
|
* not recurse back into resolution -- it takes the raw path instead. */
|
|
static __thread int hook_resolving;
|
|
|
|
static void hook_resolve(void)
|
|
{
|
|
if (hook_resolving)
|
|
return;
|
|
hook_resolving = 1;
|
|
if (!real_open) real_open = dlsym(RTLD_NEXT, "open");
|
|
if (!real_openat) real_openat = dlsym(RTLD_NEXT, "openat");
|
|
if (!real_fstatat) real_fstatat = dlsym(RTLD_NEXT, "fstatat");
|
|
if (!real_fxstatat) real_fxstatat = dlsym(RTLD_NEXT, "__fxstatat");
|
|
if (!real_fstatat64) real_fstatat64 = dlsym(RTLD_NEXT, "fstatat64");
|
|
if (!real_fxstatat64) real_fxstatat64 = dlsym(RTLD_NEXT, "__fxstatat64");
|
|
if (!real_fstatat64_time64)
|
|
real_fstatat64_time64 = dlsym(RTLD_NEXT, "__fstatat64_time64");
|
|
hook_resolving = 0;
|
|
}
|
|
|
|
/* Last-resort passthrough if even dlsym() is unusable this early. openat(2)
|
|
* rather than open(2): open is not a syscall on every architecture. */
|
|
static int raw_openat(int dfd, const char *path, int flags, mode_t mode)
|
|
{
|
|
return (int)syscall(SYS_openat, dfd, path, flags, mode);
|
|
}
|
|
|
|
static void mark(const char *path)
|
|
{
|
|
int fd;
|
|
hook_resolve();
|
|
if (!path)
|
|
return;
|
|
fd = real_open ? real_open(path, O_WRONLY | O_CREAT | O_EXCL, 0600)
|
|
: raw_openat(AT_FDCWD, path, O_WRONLY | O_CREAT | O_EXCL, 0600);
|
|
if (fd >= 0)
|
|
close(fd);
|
|
}
|
|
|
|
/* Darwin's F_GETPATH equivalent. */
|
|
static int fd_path_is(int fd, const char *wanted)
|
|
{
|
|
char procpath[64], path[PATH_MAX];
|
|
ssize_t n;
|
|
|
|
if (!wanted || fd < 0)
|
|
return 0;
|
|
snprintf(procpath, sizeof procpath, "/proc/self/fd/%d", fd);
|
|
n = readlink(procpath, path, sizeof path - 1);
|
|
if (n < 0)
|
|
return 0;
|
|
path[n] = '\0';
|
|
return strcmp(path, wanted) == 0;
|
|
}
|
|
|
|
static void restore_partial(void)
|
|
{
|
|
const char *partial = getenv("RSYNC_PARTIAL_RETRY_DIR");
|
|
const char *held = getenv("RSYNC_PARTIAL_RETRY_HELD");
|
|
if (swapped && partial && held) {
|
|
unlink(partial);
|
|
if (rename(held, partial) == 0)
|
|
swapped = 0;
|
|
}
|
|
}
|
|
|
|
/* Swap the partial dir for a symlink and report EACCES, exactly as
|
|
* fs.protected_regular would on the O_CREAT open of an existing leaf. */
|
|
static int swap_and_deny(void)
|
|
{
|
|
const char *partial = getenv("RSYNC_PARTIAL_RETRY_DIR");
|
|
const char *held = getenv("RSYNC_PARTIAL_RETRY_HELD");
|
|
if (partial && held && rename(partial, held) == 0
|
|
&& symlink("secret", partial) == 0) {
|
|
swapped = 1;
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_EACCES_MARKER"));
|
|
errno = EACCES;
|
|
return -1;
|
|
}
|
|
restore_partial();
|
|
return 0; /* caller falls through to the real call */
|
|
}
|
|
|
|
#ifdef O_TMPFILE
|
|
/* O_TMPFILE is (__O_TMPFILE | O_DIRECTORY) on Linux, so a plain & test would
|
|
* also match an ordinary O_DIRECTORY open. */
|
|
# define HOOK_TAKES_MODE(f) (((f) & O_CREAT) || (((f) & O_TMPFILE) == O_TMPFILE))
|
|
#else
|
|
# define HOOK_TAKES_MODE(f) ((f) & O_CREAT)
|
|
#endif
|
|
|
|
#ifndef O_LARGEFILE
|
|
# define O_LARGEFILE 0
|
|
#endif
|
|
|
|
static int is_victim_write(const char *path, int flags)
|
|
{
|
|
return path && strcmp(path, "victim") == 0
|
|
&& (flags & O_ACCMODE) == O_WRONLY;
|
|
}
|
|
|
|
int openat(int dfd, const char *path, int flags, ...)
|
|
{
|
|
const char *partial, *secret;
|
|
mode_t mode = 0;
|
|
int fd, saved_errno;
|
|
|
|
hook_resolve();
|
|
partial = getenv("RSYNC_PARTIAL_RETRY_DIR");
|
|
secret = getenv("RSYNC_PARTIAL_RETRY_SECRET");
|
|
|
|
if (HOOK_TAKES_MODE(flags)) {
|
|
va_list ap;
|
|
va_start(ap, flags);
|
|
mode = (mode_t)va_arg(ap, int);
|
|
va_end(ap);
|
|
}
|
|
|
|
if (!swapped && is_victim_write(path, flags) && (flags & O_CREAT)
|
|
&& fd_path_is(dfd, partial)) {
|
|
if (swap_and_deny() < 0)
|
|
return -1;
|
|
}
|
|
|
|
fd = real_openat ? real_openat(dfd, path, flags, mode)
|
|
: raw_openat(dfd, path, flags, mode);
|
|
saved_errno = errno;
|
|
|
|
/* An unconfined retry resolves the swapped parent and lands in the secret
|
|
* dir. Pin the evidence, then restore the name so later finalization
|
|
* cannot disguise which file was written. */
|
|
if (swapped && fd >= 0 && is_victim_write(path, flags)
|
|
&& !(flags & O_CREAT) && fd_path_is(dfd, secret)) {
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_OPEN_MARKER"));
|
|
restore_partial();
|
|
}
|
|
errno = saved_errno;
|
|
return fd;
|
|
}
|
|
|
|
int open(const char *path, int flags, ...)
|
|
{
|
|
mode_t mode = 0;
|
|
int fd, saved_errno;
|
|
|
|
hook_resolve();
|
|
|
|
if (HOOK_TAKES_MODE(flags)) {
|
|
va_list ap;
|
|
va_start(ap, flags);
|
|
mode = (mode_t)va_arg(ap, int);
|
|
va_end(ap);
|
|
}
|
|
|
|
if (!swapped && path && strstr(path, "pdir/victim")
|
|
&& (flags & O_ACCMODE) == O_WRONLY && (flags & O_CREAT)) {
|
|
if (swap_and_deny() < 0)
|
|
return -1;
|
|
}
|
|
|
|
fd = real_open ? real_open(path, flags, mode)
|
|
: raw_openat(AT_FDCWD, path, flags, mode);
|
|
saved_errno = errno;
|
|
|
|
/* The pre-fix recovery uses plain open() on the full pathname, which walks
|
|
* the swapped symlink with no ownership check. */
|
|
if (swapped && fd >= 0 && path && strstr(path, "pdir/victim")
|
|
&& (flags & O_ACCMODE) == O_WRONLY && !(flags & O_CREAT)) {
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_OPEN_MARKER"));
|
|
restore_partial();
|
|
}
|
|
errno = saved_errno;
|
|
return fd;
|
|
}
|
|
|
|
/* --- large-file spellings -------------------------------------------------
|
|
* Which names the RECEIVER calls is settled by its own build: where off_t is
|
|
* not already 64 bits, configure's AC_SYS_LARGEFILE adds -D_FILE_OFFSET_BITS=64
|
|
* (i386, alpha, ...), and distro CPPFLAGS add it -- along with -D_TIME_BITS=64
|
|
* -- on the 64-bit time_t ports, so glibc redirects each open()/openat()/
|
|
* fstatat() call to open64()/openat64()/fstatat64()/__fstatat64_time64().
|
|
* "objdump -T rsync | grep UND" says which set a given build imports.
|
|
*
|
|
* Which names THIS HOOK exports is a different question with a different
|
|
* answer, settled by whatever the "cc" below it defaults to -- see the #undef
|
|
* at the top. Nothing keeps the two in step, so define every spelling and let
|
|
* the loader match them up. With only the unsuffixed ones the receiver's opens
|
|
* sail straight past the hook, no EACCES is ever injected, and the test reports
|
|
* "positive control failed" having exercised nothing at all.
|
|
*
|
|
* O_LARGEFILE is the only thing open64() adds over open(), so the wrappers
|
|
* below can hand the call to the unsuffixed interposer above. */
|
|
int open64(const char *path, int flags, ...)
|
|
{
|
|
mode_t mode = 0;
|
|
|
|
if (HOOK_TAKES_MODE(flags)) {
|
|
va_list ap;
|
|
va_start(ap, flags);
|
|
mode = (mode_t)va_arg(ap, int);
|
|
va_end(ap);
|
|
}
|
|
return open(path, flags | O_LARGEFILE, mode);
|
|
}
|
|
|
|
int openat64(int dfd, const char *path, int flags, ...)
|
|
{
|
|
mode_t mode = 0;
|
|
|
|
if (HOOK_TAKES_MODE(flags)) {
|
|
va_list ap;
|
|
va_start(ap, flags);
|
|
mode = (mode_t)va_arg(ap, int);
|
|
va_end(ap);
|
|
}
|
|
return openat(dfd, path, flags | O_LARGEFILE, mode);
|
|
}
|
|
|
|
/* ona_open() decides via fstatat(..., AT_SYMLINK_NOFOLLOW) and refuses a
|
|
* component owned by neither root nor the euid. Model the attacker as a
|
|
* different uid so a retry that kept the ownership walk refuses the swap. */
|
|
static void model_foreign_owner(int rc, const char *path, struct stat *st)
|
|
{
|
|
if (rc == 0 && swapped && path && strcmp(path, "pdir") == 0
|
|
&& S_ISLNK(st->st_mode)) {
|
|
st->st_uid = geteuid() + 1;
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_FOREIGN_MARKER"));
|
|
}
|
|
}
|
|
|
|
int fstatat(int dfd, const char *path, struct stat *st, int flags)
|
|
{
|
|
int rc, saved_errno;
|
|
|
|
hook_resolve();
|
|
if (!real_fstatat) {
|
|
errno = ENOSYS;
|
|
return -1;
|
|
}
|
|
rc = real_fstatat(dfd, path, st, flags);
|
|
saved_errno = errno;
|
|
model_foreign_owner(rc, path, st);
|
|
errno = saved_errno;
|
|
return rc;
|
|
}
|
|
|
|
/* glibc < 2.33 routes fstatat() through __fxstatat(). */
|
|
int __fxstatat(int ver, int dfd, const char *path, struct stat *st, int flags)
|
|
{
|
|
int rc, saved_errno;
|
|
|
|
hook_resolve();
|
|
if (!real_fxstatat) {
|
|
errno = ENOSYS;
|
|
return -1;
|
|
}
|
|
rc = real_fxstatat(ver, dfd, path, st, flags);
|
|
saved_errno = errno;
|
|
model_foreign_owner(rc, path, st);
|
|
errno = saved_errno;
|
|
return rc;
|
|
}
|
|
|
|
/* The stat family's large-file spellings. st_mode and st_uid sit ahead of the
|
|
* timestamps in every glibc struct stat layout, so the time32/time64 variants
|
|
* of the buffer are interchangeable for the two fields touched here. */
|
|
static void model_foreign_owner64(int rc, const char *path, struct stat64 *st)
|
|
{
|
|
if (rc == 0 && swapped && path && strcmp(path, "pdir") == 0
|
|
&& S_ISLNK(st->st_mode)) {
|
|
st->st_uid = geteuid() + 1;
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_FOREIGN_MARKER"));
|
|
}
|
|
}
|
|
|
|
int fstatat64(int dfd, const char *path, struct stat64 *st, int flags)
|
|
{
|
|
int rc, saved_errno;
|
|
|
|
hook_resolve();
|
|
if (!real_fstatat64) {
|
|
errno = ENOSYS;
|
|
return -1;
|
|
}
|
|
rc = real_fstatat64(dfd, path, st, flags);
|
|
saved_errno = errno;
|
|
model_foreign_owner64(rc, path, st);
|
|
errno = saved_errno;
|
|
return rc;
|
|
}
|
|
|
|
int __fxstatat64(int ver, int dfd, const char *path, struct stat64 *st, int flags)
|
|
{
|
|
int rc, saved_errno;
|
|
|
|
hook_resolve();
|
|
if (!real_fxstatat64) {
|
|
errno = ENOSYS;
|
|
return -1;
|
|
}
|
|
rc = real_fxstatat64(ver, dfd, path, st, flags);
|
|
saved_errno = errno;
|
|
model_foreign_owner64(rc, path, st);
|
|
errno = saved_errno;
|
|
return rc;
|
|
}
|
|
|
|
/* A 32-bit port built with -D_TIME_BITS=64 (Debian's armhf/armel/hppa/powerpc,
|
|
* ...) reaches fstatat() under this third name. */
|
|
int __fstatat64_time64(int dfd, const char *path, struct stat64 *st, int flags)
|
|
{
|
|
int rc, saved_errno;
|
|
|
|
hook_resolve();
|
|
if (!real_fstatat64_time64) {
|
|
errno = ENOSYS;
|
|
return -1;
|
|
}
|
|
rc = real_fstatat64_time64(dfd, path, st, flags);
|
|
saved_errno = errno;
|
|
model_foreign_owner64(rc, path, st);
|
|
errno = saved_errno;
|
|
return rc;
|
|
}
|
|
|
|
__attribute__((constructor)) static void hook_loaded(void)
|
|
{
|
|
hook_resolve();
|
|
mark(getenv("RSYNC_PARTIAL_RETRY_LOAD_MARKER"));
|
|
}
|
|
|
|
__attribute__((destructor)) static void hook_unload(void)
|
|
{
|
|
restore_partial();
|
|
}
|
|
'''
|
|
|
|
|
|
def run():
|
|
base = SCRATCHDIR / 'partial-protected-regular-retry-linux'
|
|
rmtree(base)
|
|
src = base / 'src'
|
|
mod = base / 'mod'
|
|
partial = mod / 'pdir'
|
|
held = mod / '.pdir-held'
|
|
secret = mod / 'secret'
|
|
markers = {
|
|
'load': base / 'hook-loaded',
|
|
'eacces': base / 'operator-open-eacces',
|
|
'retry': base / 'unconfined-retry-open',
|
|
'foreign': base / 'owner-check-would-refuse',
|
|
}
|
|
for directory in (src, partial, secret):
|
|
directory.mkdir(parents=True)
|
|
|
|
old_partial = b'OLD-PARTIAL-BASIS-' * 4096
|
|
new_source = b'NEW-REMOTE-PAYLOAD' * 4096
|
|
secret_data = b'OTHER-USER-SECRET!' * 4096
|
|
if not (len(old_partial) == len(new_source) == len(secret_data)):
|
|
raise AssertionError('PoC payloads must be equal length')
|
|
(partial / 'victim').write_bytes(old_partial)
|
|
(secret / 'victim').write_bytes(secret_data)
|
|
(src / 'victim').write_bytes(new_source)
|
|
|
|
hook_src = base / 'hook.c'
|
|
hook_lib = base / 'hook.so'
|
|
hook_src.write_text(hook_code)
|
|
build = subprocess.run(
|
|
['cc', '-shared', '-fPIC', '-o', str(hook_lib), str(hook_src), '-ldl'],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
if build.returncode != 0:
|
|
test_skipped(f'cannot build LD_PRELOAD partial retry hook: {build.stdout!r}')
|
|
|
|
run_env = os.environ.copy()
|
|
run_env.update({
|
|
'LD_PRELOAD': str(hook_lib),
|
|
'RSYNC_PARTIAL_RETRY_DIR': str(partial),
|
|
'RSYNC_PARTIAL_RETRY_HELD': str(held),
|
|
'RSYNC_PARTIAL_RETRY_SECRET': str(secret),
|
|
'RSYNC_PARTIAL_RETRY_LOAD_MARKER': str(markers['load']),
|
|
'RSYNC_PARTIAL_RETRY_EACCES_MARKER': str(markers['eacces']),
|
|
'RSYNC_PARTIAL_RETRY_OPEN_MARKER': str(markers['retry']),
|
|
'RSYNC_PARTIAL_RETRY_FOREIGN_MARKER': str(markers['foreign']),
|
|
})
|
|
|
|
result = subprocess.run(
|
|
rsync_argv('-rtI', '--partial', '--partial-dir=pdir',
|
|
'--no-inc-recursive', f'{src}/', f'{mod}/'),
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=run_env)
|
|
|
|
if held.exists():
|
|
if partial.is_symlink():
|
|
partial.unlink()
|
|
held.rename(partial)
|
|
|
|
ctx = f'rc={result.returncode}, output={result.stdout!r}'
|
|
# A negative returncode is death by signal. That is never a reason to skip:
|
|
# the hook killing the process under test is a bug in the hook, and reporting
|
|
# it as "not loaded" is exactly how a SIGSEGV on AlmaLinux 8 hid for weeks --
|
|
# the marker is written by the hook, so a crash before that looks identical
|
|
# to the hook never having loaded.
|
|
if result.returncode is not None and result.returncode < 0:
|
|
test_fail(f'rsync died by signal {-result.returncode} under the '
|
|
f'LD_PRELOAD hook ({ctx})')
|
|
if not markers['load'].exists():
|
|
test_skipped(f'LD_PRELOAD hook was not loaded ({ctx})')
|
|
if not markers['eacces'].exists():
|
|
test_fail('positive control failed: receiver did not open the existing '
|
|
f'partial file with O_CREAT ({ctx})')
|
|
|
|
# The escape check must come before the ownership-walk control below: on a
|
|
# vulnerable build no walk runs at all, and that has to be reported as the
|
|
# escape it is rather than as an inconclusive control failure.
|
|
observed = (secret / 'victim').read_bytes()
|
|
if observed == new_source:
|
|
test_fail('EACCES recovery dropped partial-dir ownership policy and '
|
|
f'overwrote a different file through the raced symlink ({ctx})')
|
|
if observed != secret_data:
|
|
test_fail(f'outside-policy target has unexpected contents ({ctx})')
|
|
if markers['retry'].exists():
|
|
test_fail('an unconfined retry opened the victim through the raced '
|
|
f'partial-dir symlink ({ctx})')
|
|
|
|
# Anti-vacuity: nothing was overwritten -- prove that is the ownership walk
|
|
# refusing the swap, not the scenario failing to arm.
|
|
if not markers['foreign'].exists():
|
|
test_fail('inconclusive: the recovery never inspected the raced '
|
|
f'partial-dir symlink, so no ownership check ran ({ctx})')
|
|
|
|
|
|
run()
|
|
print('protected_regular compatibility retry retained partial-dir ownership policy')
|