mirror of
https://github.com/google/brotli.git
synced 2026-07-08 17:56:58 +00:00
Convert CopyStat regression tests to Python suite
This commit is contained in:
339
tests/regression/t01/copystat_regression_test.py
Executable file
339
tests/regression/t01/copystat_regression_test.py
Executable file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Brotli Authors. All rights reserved.
|
||||
#
|
||||
# Distributed under MIT license.
|
||||
# See file LICENSE for detail or copy at https://opensource.org/licenses/MIT
|
||||
|
||||
"""Regression suite for brotli CLI CopyStat behavior.
|
||||
|
||||
Usage:
|
||||
python3 tests/regression/t01/copystat_regression_test.py /path/to/brotli
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
PLAIN_BYTES = b"A" * 65537
|
||||
TARGET_BYTES = b"TARGET\n"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def umask(mask):
|
||||
previous = os.umask(mask)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.umask(previous)
|
||||
|
||||
|
||||
def file_mode(path):
|
||||
return stat.S_IMODE(os.stat(path).st_mode)
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
class CopyStatRegressionTest(unittest.TestCase):
|
||||
brotli = None
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmpdir.cleanup)
|
||||
self.workdir = Path(self.tmpdir.name)
|
||||
|
||||
def write_plain(self, path):
|
||||
path.write_bytes(PLAIN_BYTES)
|
||||
|
||||
def write_target(self, path):
|
||||
path.write_bytes(TARGET_BYTES)
|
||||
|
||||
def run_brotli(self, *args, input_bytes=None, env=None, check=True):
|
||||
proc = subprocess.run(
|
||||
[str(self.brotli)] + [str(arg) for arg in args],
|
||||
input=input_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
check=False)
|
||||
if check and proc.returncode != 0:
|
||||
self.fail(
|
||||
"brotli exited with %d\nstdout:\n%s\nstderr:\n%s" % (
|
||||
proc.returncode,
|
||||
proc.stdout.decode("utf-8", "replace"),
|
||||
proc.stderr.decode("utf-8", "replace")))
|
||||
return proc
|
||||
|
||||
def compress(self, src, dst, no_copy_stat=False):
|
||||
args = ["-f", "-k"]
|
||||
if no_copy_stat:
|
||||
args.append("-n")
|
||||
args.extend([src, "-o", dst])
|
||||
return self.run_brotli(*args)
|
||||
|
||||
def decompress(self, src, dst, no_copy_stat=False):
|
||||
args = ["-d", "-f"]
|
||||
if no_copy_stat:
|
||||
args.append("-n")
|
||||
args.extend([src, "-o", dst])
|
||||
return self.run_brotli(*args)
|
||||
|
||||
def test_modes_propagate_on_compress_and_decompress(self):
|
||||
modes = [0o600, 0o640, 0o644, 0o664, 0o660,
|
||||
0o400, 0o444, 0o755, 0o700, 0o604]
|
||||
|
||||
with umask(0o022):
|
||||
for mode in modes:
|
||||
with self.subTest(mode="%03o" % mode):
|
||||
case_dir = self.workdir / ("%03o" % mode)
|
||||
case_dir.mkdir()
|
||||
src = case_dir / "in.bin"
|
||||
compressed = case_dir / "in.bin.br"
|
||||
decoded = case_dir / "out.bin"
|
||||
|
||||
self.write_plain(src)
|
||||
os.chmod(src, mode)
|
||||
|
||||
self.compress(src, compressed)
|
||||
self.decompress(compressed, decoded)
|
||||
|
||||
self.assertEqual(file_mode(compressed), mode)
|
||||
self.assertEqual(file_mode(decoded), mode)
|
||||
self.assertEqual(decoded.read_bytes(), PLAIN_BYTES)
|
||||
|
||||
def test_special_mode_bits_are_masked(self):
|
||||
with umask(0o022):
|
||||
src = self.workdir / "in.bin"
|
||||
compressed = self.workdir / "in.bin.br"
|
||||
self.write_plain(src)
|
||||
|
||||
os.chmod(src, 0o3644)
|
||||
actual_input_mode = file_mode(src)
|
||||
if actual_input_mode == 0o644:
|
||||
self.skipTest("filesystem stripped special mode bits from input")
|
||||
|
||||
self.compress(src, compressed)
|
||||
compressed_mode = file_mode(compressed)
|
||||
|
||||
self.assertEqual(compressed_mode & 0o7000, 0)
|
||||
self.assertEqual(compressed_mode & 0o777, actual_input_mode & 0o777)
|
||||
|
||||
def test_no_copy_stat_flag_suppresses_mode_copy(self):
|
||||
with umask(0o022):
|
||||
src = self.workdir / "in.bin"
|
||||
without_copy = self.workdir / "without_copy.br"
|
||||
with_copy = self.workdir / "with_copy.br"
|
||||
self.write_plain(src)
|
||||
os.chmod(src, 0o606)
|
||||
|
||||
self.compress(src, without_copy, no_copy_stat=True)
|
||||
self.compress(src, with_copy)
|
||||
|
||||
self.assertNotEqual(file_mode(without_copy), 0o606)
|
||||
self.assertEqual(file_mode(with_copy), 0o606)
|
||||
|
||||
def test_timestamp_is_copied(self):
|
||||
with umask(0o022):
|
||||
src = self.workdir / "in.bin"
|
||||
compressed = self.workdir / "in.bin.br"
|
||||
decoded = self.workdir / "out.bin"
|
||||
self.write_plain(src)
|
||||
|
||||
expected_mtime = 1577934245
|
||||
os.utime(src, (expected_mtime, expected_mtime))
|
||||
|
||||
self.compress(src, compressed)
|
||||
self.decompress(compressed, decoded)
|
||||
|
||||
self.assertEqual(int(os.stat(compressed).st_mtime), expected_mtime)
|
||||
self.assertEqual(int(os.stat(decoded).st_mtime), expected_mtime)
|
||||
|
||||
def test_stdin_input_has_no_path_to_copy_from(self):
|
||||
payload = b"stdin -> compressed file\n"
|
||||
compressed = self.run_brotli("-c", input_bytes=payload).stdout
|
||||
|
||||
decoded = self.workdir / "decoded.txt"
|
||||
self.run_brotli("-d", "-o", decoded, input_bytes=compressed)
|
||||
|
||||
self.assertEqual(decoded.read_bytes(), payload)
|
||||
self.assertEqual(file_mode(decoded) & ~0o666, 0)
|
||||
|
||||
def test_stdout_path_skips_copystat(self):
|
||||
payload = b"hello brotli stdout pipeline\n"
|
||||
|
||||
compressed = self.run_brotli("-c", input_bytes=payload).stdout
|
||||
decoded = self.run_brotli("-d", "-c", input_bytes=compressed).stdout
|
||||
|
||||
self.assertEqual(decoded, payload)
|
||||
|
||||
def test_content_roundtrip(self):
|
||||
cases = {
|
||||
"small-ascii": b"hello brotli\n",
|
||||
"empty": b"",
|
||||
"plain-65537": PLAIN_BYTES,
|
||||
"aligned-65536": b"\0" * 65536,
|
||||
"deterministic-binary": bytes((i * 73 + 41) % 256
|
||||
for i in range(12288)),
|
||||
}
|
||||
|
||||
with umask(0o022):
|
||||
for label, data in cases.items():
|
||||
with self.subTest(label=label):
|
||||
case_dir = self.workdir / label
|
||||
case_dir.mkdir()
|
||||
src = case_dir / "in.bin"
|
||||
compressed = case_dir / "in.bin.br"
|
||||
decoded = case_dir / "out.bin"
|
||||
src.write_bytes(data)
|
||||
os.chmod(src, 0o644)
|
||||
|
||||
self.compress(src, compressed)
|
||||
if data:
|
||||
self.assertGreater(compressed.stat().st_size, 0)
|
||||
self.decompress(compressed, decoded)
|
||||
|
||||
self.assertEqual(decoded.read_bytes(), data)
|
||||
|
||||
def test_fclose_swap_does_not_redirect_copystat(self):
|
||||
compressed, out_path, target_path = self.prepare_attack_files()
|
||||
pre_hash = sha256(target_path)
|
||||
|
||||
self.run_brotli_with_swap(compressed, out_path, target_path)
|
||||
|
||||
self.assertTrue(out_path.is_symlink())
|
||||
self.assertEqual(file_mode(target_path), 0o600)
|
||||
self.assertEqual(sha256(target_path), pre_hash)
|
||||
|
||||
def test_fclose_swap_with_no_copy_stat_leaves_target_unchanged(self):
|
||||
compressed, out_path, target_path = self.prepare_attack_files()
|
||||
pre_hash = sha256(target_path)
|
||||
|
||||
self.run_brotli_with_swap(compressed, out_path, target_path,
|
||||
no_copy_stat=True)
|
||||
|
||||
self.assertTrue(out_path.is_symlink())
|
||||
self.assertEqual(file_mode(target_path), 0o600)
|
||||
self.assertEqual(sha256(target_path), pre_hash)
|
||||
|
||||
def prepare_attack_files(self):
|
||||
src = self.workdir / "plain.bin"
|
||||
compressed = self.workdir / "plain.bin.br"
|
||||
out_path = self.workdir / "out"
|
||||
target_path = self.workdir / "target.txt"
|
||||
|
||||
self.write_plain(src)
|
||||
self.write_target(target_path)
|
||||
os.chmod(src, 0o644)
|
||||
os.chmod(target_path, 0o600)
|
||||
self.compress(src, compressed)
|
||||
os.chmod(compressed, 0o644)
|
||||
|
||||
return compressed, out_path, target_path
|
||||
|
||||
def run_brotli_with_swap(self, compressed, out_path, target_path,
|
||||
no_copy_stat=False):
|
||||
env = dict(os.environ)
|
||||
env["BROTLI_SWAP_OUTPUT_ABS"] = str(out_path)
|
||||
env["BROTLI_SWAP_TARGET_ABS"] = str(target_path)
|
||||
env["LD_PRELOAD"] = self.ld_preload_value()
|
||||
|
||||
args = ["-d", "-f"]
|
||||
if no_copy_stat:
|
||||
args.append("-n")
|
||||
args.extend(["-o", out_path, compressed])
|
||||
|
||||
proc = self.run_brotli(*args, env=env, check=False)
|
||||
if proc.returncode != 0:
|
||||
self.fail(
|
||||
"brotli with fclose_swap exited with %d\nstdout:\n%s\nstderr:\n%s" % (
|
||||
proc.returncode,
|
||||
proc.stdout.decode("utf-8", "replace"),
|
||||
proc.stderr.decode("utf-8", "replace")))
|
||||
|
||||
def ld_preload_value(self):
|
||||
preloads = []
|
||||
asan_runtime = self.asan_runtime()
|
||||
if asan_runtime:
|
||||
preloads.append(asan_runtime)
|
||||
preloads.append(str(self.swap_helper()))
|
||||
existing = os.environ.get("LD_PRELOAD")
|
||||
if existing:
|
||||
preloads.append(existing)
|
||||
return ":".join(preloads)
|
||||
|
||||
def swap_helper(self):
|
||||
if not Path("/proc/self/fd").is_dir():
|
||||
self.skipTest("fclose_swap helper requires /proc/self/fd")
|
||||
cc = shutil.which("cc")
|
||||
if cc is None:
|
||||
self.skipTest("cc is required to build fclose_swap.so")
|
||||
|
||||
helper_src = Path(__file__).with_name("fclose_swap.c")
|
||||
helper_out = self.workdir / "libfclose_swap.so"
|
||||
proc = subprocess.run(
|
||||
[cc, "-shared", "-fPIC", str(helper_src), "-o", str(helper_out),
|
||||
"-ldl"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False)
|
||||
if proc.returncode != 0:
|
||||
self.fail(
|
||||
"failed to build fclose_swap.so\nstdout:\n%s\nstderr:\n%s" % (
|
||||
proc.stdout.decode("utf-8", "replace"),
|
||||
proc.stderr.decode("utf-8", "replace")))
|
||||
return helper_out
|
||||
|
||||
def asan_runtime(self):
|
||||
try:
|
||||
proc = subprocess.run(["ldd", str(self.brotli)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if b"libasan" not in proc.stdout + proc.stderr:
|
||||
return None
|
||||
|
||||
cc = shutil.which("cc")
|
||||
if cc is None:
|
||||
return None
|
||||
proc = subprocess.run([cc, "-print-file-name=libasan.so"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False)
|
||||
candidate = proc.stdout.strip()
|
||||
if candidate and Path(candidate).is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("brotli", help="path to the brotli CLI binary to test")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
brotli = Path(args.brotli).resolve()
|
||||
if not brotli.is_file():
|
||||
parser.error("%s is not a file" % brotli)
|
||||
CopyStatRegressionTest.brotli = brotli
|
||||
|
||||
unittest.main(argv=[__file__], verbosity=2 if args.verbose else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,9 @@
|
||||
/* Copyright 2026 The Brotli Authors. All rights reserved.
|
||||
|
||||
Distributed under MIT license.
|
||||
See file LICENSE for detail or copy at https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "usage: $0 /path/to/brotli /path/to/libfclose_swap.so" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
swap_so=$(readlink -f -- "$2")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
ld_preload="$swap_so"
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$script_dir/plain.bin.br" \
|
||||
"$script_dir/target.txt" "$workdir"/
|
||||
chmod 0644 "$workdir/plain.bin"
|
||||
chmod 0644 "$workdir/plain.bin.br"
|
||||
chmod 0600 "$workdir/target.txt"
|
||||
|
||||
out_path="$workdir/out"
|
||||
target_path="$workdir/target.txt"
|
||||
|
||||
if ldd "$brotli_bin" | grep -q 'libasan'; then
|
||||
asan_runtime=$(cc -print-file-name=libasan.so)
|
||||
if [[ -f "$asan_runtime" ]]; then
|
||||
ld_preload="$asan_runtime:$ld_preload"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${LD_PRELOAD:-}" ]]; then
|
||||
ld_preload="$ld_preload:$LD_PRELOAD"
|
||||
fi
|
||||
|
||||
env \
|
||||
BROTLI_SWAP_OUTPUT_ABS="$out_path" \
|
||||
BROTLI_SWAP_TARGET_ABS="$target_path" \
|
||||
LD_PRELOAD="$ld_preload" \
|
||||
"$brotli_bin" -d -o "$out_path" "$workdir/plain.bin.br"
|
||||
|
||||
if [[ ! -L "$out_path" ]]; then
|
||||
echo "output path was not swapped to a symlink" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(stat -c '%a' "$target_path")" != "644" ]]; then
|
||||
echo "target mode was not flipped to 0644" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! cmp -s -- "$workdir/target.txt" "$script_dir/target.txt"; then
|
||||
echo "target contents changed; expected metadata-only effect" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'T-01 OK: target mode flipped to %s via post-close CopyStat path\n' \
|
||||
"$(stat -c '%a' "$target_path")"
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# T-01 master test runner.
|
||||
#
|
||||
# Runs all tests for google/brotli#1461 (CopyStat TOCTOU) and summarizes
|
||||
# results. Tests that require the LD_PRELOAD helper receive its path as
|
||||
# the second argument; tests that don't are invoked with the brotli binary
|
||||
# only.
|
||||
#
|
||||
# Usage:
|
||||
# run_all.sh /path/to/brotli /path/to/libfclose_swap.so
|
||||
#
|
||||
# Exit code: 0 if every test passed, non-zero otherwise.
|
||||
#
|
||||
# Individual tests exit with 0 on pass, non-zero on fail. Infrastructure
|
||||
# failures from test_copystat_swap_fixed_strict.sh (exit 2) are surfaced
|
||||
# distinctly in the summary.
|
||||
set -u
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "usage: $0 /path/to/brotli /path/to/libfclose_swap.so" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin="$1"
|
||||
swap_so="$2"
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
# Tests that need only the brotli binary.
|
||||
brotli_only=(
|
||||
"test_copystat_positive.sh"
|
||||
"test_copystat_various_modes.sh"
|
||||
"test_copystat_mode_mask.sh"
|
||||
"test_copystat_stdout_skip.sh"
|
||||
"test_copystat_stdin_input.sh"
|
||||
"test_copystat_no_copy_stat_flag.sh"
|
||||
"test_copystat_roundtrip.sh"
|
||||
"test_copystat_timestamp.sh"
|
||||
)
|
||||
|
||||
# Tests that need both the brotli binary and the LD_PRELOAD helper.
|
||||
attack_tests=(
|
||||
"test_copystat_swap_fixed.sh"
|
||||
"test_copystat_swap_fixed_strict.sh"
|
||||
"test_copystat_swap_with_no_copy_stat.sh"
|
||||
)
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
infra_fail=0
|
||||
fail_names=()
|
||||
infra_names=()
|
||||
|
||||
run_one() {
|
||||
local script="$1"
|
||||
shift
|
||||
local label="$script"
|
||||
printf '=== %s ===\n' "$label"
|
||||
if "$script_dir/$script" "$@"; then
|
||||
passed=$((passed + 1))
|
||||
printf 'PASS: %s\n\n' "$label"
|
||||
return 0
|
||||
fi
|
||||
local rc=$?
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
infra_fail=$((infra_fail + 1))
|
||||
infra_names+=("$label")
|
||||
printf 'INFRA: %s (exit %d)\n\n' "$label" "$rc"
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fail_names+=("$label")
|
||||
printf 'FAIL: %s (exit %d)\n\n' "$label" "$rc"
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
for t in "${brotli_only[@]}"; do
|
||||
run_one "$t" "$brotli_bin" || true
|
||||
done
|
||||
|
||||
for t in "${attack_tests[@]}"; do
|
||||
run_one "$t" "$brotli_bin" "$swap_so" || true
|
||||
done
|
||||
|
||||
total=$((passed + failed + infra_fail))
|
||||
printf -- '----- T-01 SUMMARY -----\n'
|
||||
printf 'passed: %d / %d\n' "$passed" "$total"
|
||||
if [[ $failed -ne 0 ]]; then
|
||||
printf 'failed: %d\n' "$failed"
|
||||
for n in "${fail_names[@]}"; do printf ' - %s\n' "$n"; done
|
||||
fi
|
||||
if [[ $infra_fail -ne 0 ]]; then
|
||||
printf 'infra-failures: %d\n' "$infra_fail"
|
||||
for n in "${infra_names[@]}"; do printf ' - %s\n' "$n"; done
|
||||
fi
|
||||
|
||||
if [[ $failed -ne 0 || $infra_fail -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1 +0,0 @@
|
||||
TARGET
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mode-mask test for T-01.
|
||||
#
|
||||
# PLAN.md §3.1 specifies that CopyStat() calls:
|
||||
# fchmod(fd, statbuf.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO));
|
||||
#
|
||||
# i.e. only the 9 low permission bits are copied. Setuid (04000), setgid
|
||||
# (02000), and sticky (01000) bits MUST NOT propagate from the input to
|
||||
# the output: that would be a silent privilege-escalation surprise.
|
||||
#
|
||||
# This test sets a setgid-like mode on the input (chmod will typically
|
||||
# clear setuid on a non-executable file, so we test setgid + sticky),
|
||||
# compresses, and asserts the compressed output's mode lacks those bits.
|
||||
#
|
||||
# Usage: test_copystat_mode_mask.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 0022
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$workdir/in.bin"
|
||||
|
||||
# Set setgid + sticky + rw-r--r--. Setuid (04000) is often cleared by kernel
|
||||
# on non-executable regular files owned by non-root; setgid (02000) is the
|
||||
# portable bit we can reliably set without root.
|
||||
chmod 03644 "$workdir/in.bin" || true
|
||||
|
||||
actual_input_mode=$(stat -c '%a' "$workdir/in.bin")
|
||||
# Proceed only if the kernel accepted at least some special bit; otherwise
|
||||
# the test cannot meaningfully assert anything.
|
||||
if [[ "$actual_input_mode" == "644" ]]; then
|
||||
echo "SKIP: filesystem/kernel stripped all special mode bits from input" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"$brotli_bin" -fk "$workdir/in.bin" -o "$workdir/in.bin.br"
|
||||
compressed_mode=$(stat -c '%a' "$workdir/in.bin.br")
|
||||
|
||||
# The compressed output should carry the 9 permission bits only (644), NOT
|
||||
# any of the special bits. Extract the 3 lowest octal digits.
|
||||
# stat -c '%a' returns either "NNN" or "NNNN" depending on whether any
|
||||
# special bit is set in the output.
|
||||
if [[ ${#compressed_mode} -eq 4 ]]; then
|
||||
echo "FAIL: output carries special bits (mode=$compressed_mode); only 9" \
|
||||
"permission bits should be copied" >&2
|
||||
echo " input_mode=$actual_input_mode, output_mode=$compressed_mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also assert the 9 perm bits match the input's 9 perm bits.
|
||||
input_perm="${actual_input_mode: -3}"
|
||||
if [[ "$compressed_mode" != "$input_perm" ]]; then
|
||||
echo "FAIL: 9-bit permission part did not propagate (input=$input_perm," \
|
||||
"output=$compressed_mode)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 MODE MASK: special bits not copied (input=$actual_input_mode," \
|
||||
"output=$compressed_mode)"
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# --no-copy-stat (-n) flag test for T-01.
|
||||
#
|
||||
# PLAN.md §4 row 1: when copy_stat == BROTLI_FALSE (user passed -n /
|
||||
# --no-copy-stat), CopyStat() must not be called. The new CloseFiles()
|
||||
# preserves this condition.
|
||||
#
|
||||
# We verify two things:
|
||||
# 1. With -n, the output file does NOT inherit the input mode. Instead,
|
||||
# it gets whatever the default mode is (derived from umask and the
|
||||
# OpenOutputFile creat() mode).
|
||||
# 2. Without -n, the same input reliably produces an output with the
|
||||
# input's mode — this cross-checks that (1)'s behavior is due to the
|
||||
# flag, not some other failure to propagate mode.
|
||||
#
|
||||
# Usage: test_copystat_no_copy_stat_flag.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Set a predictable umask so the default-mode assertion below is stable.
|
||||
umask 0022
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$workdir/in.bin"
|
||||
# Choose an unusual mode distinct from the 0644 default so we can detect
|
||||
# whether it propagated. 0600 was already used by the swap repro; choose
|
||||
# 0606 here to avoid colliding with "would-be 0644 with group stripped".
|
||||
chmod 0606 "$workdir/in.bin"
|
||||
|
||||
# Case A: with -n, mode must NOT propagate.
|
||||
"$brotli_bin" -fkn "$workdir/in.bin" -o "$workdir/out_n.br"
|
||||
mode_n=$(stat -c '%a' "$workdir/out_n.br")
|
||||
if [[ "$mode_n" == "606" ]]; then
|
||||
echo "FAIL: with -n, mode 0606 propagated anyway — CopyStat was still called" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case B: without -n, mode MUST propagate.
|
||||
"$brotli_bin" -fk "$workdir/in.bin" -o "$workdir/out_y.br"
|
||||
mode_y=$(stat -c '%a' "$workdir/out_y.br")
|
||||
if [[ "$mode_y" != "606" ]]; then
|
||||
echo "FAIL: without -n, mode 0606 did not propagate (got $mode_y)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 NO-COPY-STAT: -n suppressed CopyStat, default preserved elsewhere"
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Positive regression test for T-01 (google/brotli#1461).
|
||||
#
|
||||
# After the fix, CopyStat() uses fchmod() / fchown() via fileno(fout) to copy
|
||||
# the input file's permission bits onto the output file. This test confirms
|
||||
# that behavior is preserved by the fix (i.e. we did not regress by breaking
|
||||
# CopyStat entirely).
|
||||
#
|
||||
# Creates a plaintext input with an unusual mode (0640, distinct from the
|
||||
# typical 0644 default), decompresses a compressed copy to a fresh output
|
||||
# path, and asserts the output file carries the input file's 0640 mode.
|
||||
#
|
||||
# Usage: test_copystat_positive.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Default umask can mask out group/other bits on new files; start permissive.
|
||||
umask 0022
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$workdir/input.bin"
|
||||
chmod 0640 "$workdir/input.bin"
|
||||
|
||||
# Compress (use -k to keep the input; output mode also gets CopyStat-copied).
|
||||
"$brotli_bin" -fk "$workdir/input.bin" -o "$workdir/input.bin.br"
|
||||
|
||||
input_mode=$(stat -c '%a' "$workdir/input.bin")
|
||||
compressed_mode=$(stat -c '%a' "$workdir/input.bin.br")
|
||||
if [[ "$input_mode" != "640" ]]; then
|
||||
echo "pre-condition failed: input mode is $input_mode, expected 640" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$compressed_mode" != "640" ]]; then
|
||||
echo "POSITIVE FAIL: compress direction did not copy mode" >&2
|
||||
echo " input mode = $input_mode, compressed mode = $compressed_mode (want 640)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Decompress to a fresh output path.
|
||||
rm -f -- "$workdir/out.bin"
|
||||
"$brotli_bin" -d "$workdir/input.bin.br" -o "$workdir/out.bin"
|
||||
|
||||
out_mode=$(stat -c '%a' "$workdir/out.bin")
|
||||
if [[ "$out_mode" != "640" ]]; then
|
||||
echo "POSITIVE FAIL: decompress did not copy mode from input.bin.br to out.bin" >&2
|
||||
echo " compressed mode = $compressed_mode, decompressed mode = $out_mode (want 640)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Content round-trip check: decompressed output must match the original.
|
||||
if ! cmp -s -- "$workdir/input.bin" "$workdir/out.bin"; then
|
||||
echo "POSITIVE FAIL: decompressed content differs from original input" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 POSITIVE: CopyStat still propagates 0640 through compress+decompress"
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Content-roundtrip regression test for T-01.
|
||||
#
|
||||
# PLAN.md §5.3 asks us to confirm the basic compress+decompress round-trip
|
||||
# is not broken by the CopyStat reorder in CloseFiles(). The reorder moves
|
||||
# CopyStat() before fclose(); if that reorder accidentally also moved or
|
||||
# dropped data-writing code, content would diverge.
|
||||
#
|
||||
# Inputs tested:
|
||||
# - Small ASCII text
|
||||
# - Empty file (edge case: zero-length input)
|
||||
# - Medium binary (plain.bin, 65537 bytes — odd size to hit unaligned paths)
|
||||
# - A file exactly on a common buffer boundary (65536 bytes)
|
||||
#
|
||||
# For each: compress, then decompress, then cmp against the original.
|
||||
# Also asserts each intermediate file exists and is non-zero where
|
||||
# appropriate, which guards against "CopyStat ran before write completed"
|
||||
# style regressions from a bad reorder.
|
||||
#
|
||||
# Usage: test_copystat_roundtrip.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 0022
|
||||
|
||||
roundtrip() {
|
||||
local label="$1" src="$2"
|
||||
local sub
|
||||
sub=$(mktemp -d -p "$workdir")
|
||||
cp -- "$src" "$sub/in.bin"
|
||||
chmod 0644 "$sub/in.bin"
|
||||
|
||||
"$brotli_bin" -fk "$sub/in.bin" -o "$sub/in.bin.br"
|
||||
if [[ ! -s "$sub/in.bin.br" && -s "$sub/in.bin" ]]; then
|
||||
echo "FAIL [$label]: compressed output is empty but input was not" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$brotli_bin" -df "$sub/in.bin.br" -o "$sub/out.bin"
|
||||
if ! cmp -s -- "$sub/in.bin" "$sub/out.bin"; then
|
||||
echo "FAIL [$label]: round-trip content mismatch" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Case: small ASCII text.
|
||||
printf 'hello brotli\n' > "$workdir/small.txt"
|
||||
roundtrip "small-ascii" "$workdir/small.txt"
|
||||
|
||||
# Case: empty file.
|
||||
: > "$workdir/empty.bin"
|
||||
roundtrip "empty" "$workdir/empty.bin"
|
||||
|
||||
# Case: the stock 65537-byte plain.bin used by the T-01 assets.
|
||||
roundtrip "plain-65537" "$script_dir/plain.bin"
|
||||
|
||||
# Case: exactly 65536 bytes (a buffer-boundary-adjacent size).
|
||||
dd if=/dev/zero of="$workdir/aligned.bin" bs=65536 count=1 status=none
|
||||
roundtrip "aligned-65536" "$workdir/aligned.bin"
|
||||
|
||||
# Case: high-entropy binary (so compression is a no-op-ish) to exercise the
|
||||
# close path with minimal buffering differences.
|
||||
dd if=/dev/urandom of="$workdir/random.bin" bs=4096 count=3 status=none
|
||||
roundtrip "random-12288" "$workdir/random.bin"
|
||||
|
||||
echo "T-01 ROUNDTRIP: 5 input shapes round-tripped cleanly"
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stdin-input test for T-01.
|
||||
#
|
||||
# PLAN.md §4 row 3: when input is stdin (context->current_input_path == NULL),
|
||||
# CopyStat() early-returns on the input_path == NULL branch:
|
||||
# if (input_path == NULL || fout == NULL) { return; }
|
||||
# There is no fd-based metadata to copy *from*.
|
||||
#
|
||||
# We exercise this by piping content into brotli with an explicit -o output
|
||||
# path. The input has no pathname; the output does. CopyStat() must:
|
||||
# - not crash
|
||||
# - not try to stat() a NULL path
|
||||
# - not leave the output with a wrong mode (no mode is copied, so the
|
||||
# output file just gets whatever the process umask / OpenOutputFile
|
||||
# grants it)
|
||||
#
|
||||
# Usage: test_copystat_stdin_input.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 0022
|
||||
|
||||
payload='stdin -> compressed file'
|
||||
out_path="$workdir/out.br"
|
||||
stderr_log="$workdir/stderr.log"
|
||||
|
||||
# Compress from stdin to an explicit output file.
|
||||
echo "$payload" | "$brotli_bin" -c > "$out_path.stdio" 2>"$stderr_log"
|
||||
# Now decompress it from stdin to a real output file path (exercises the
|
||||
# asymmetric case: NULL input_path, non-NULL output_path).
|
||||
"$brotli_bin" -d -o "$workdir/decoded.txt" < "$out_path.stdio" \
|
||||
2>>"$stderr_log"
|
||||
|
||||
# File exists and contents match.
|
||||
if [[ ! -f "$workdir/decoded.txt" ]]; then
|
||||
echo "FAIL: decoded.txt was not created" >&2
|
||||
exit 1
|
||||
fi
|
||||
decoded=$(cat "$workdir/decoded.txt")
|
||||
if [[ "$decoded" != "$payload" ]]; then
|
||||
echo "FAIL: decoded content differs from payload" >&2
|
||||
echo " got: $decoded" >&2
|
||||
echo " wanted: $payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity: the output file must have a sane regular-file mode.
|
||||
# We explicitly do NOT assert any particular permission mode here, because
|
||||
# when input is stdin there is no source mode to copy.
|
||||
out_mode=$(stat -c '%a' "$workdir/decoded.txt")
|
||||
case "$out_mode" in
|
||||
"644"|"664"|"640"|"600"|"666") ;; # any typical creat()+umask outcome
|
||||
*)
|
||||
echo "WARN: unexpected output mode $out_mode (not a failure, just noting)" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Guard against brotli diagnostics caused by a null-path deref.
|
||||
if [[ -s "$stderr_log" ]]; then
|
||||
filtered=$(grep -v -E \
|
||||
-e '^==[0-9]+==' \
|
||||
-e '^SUMMARY:' \
|
||||
-e 'AddressSanitizer' \
|
||||
-e 'LeakSanitizer' \
|
||||
-e 'UndefinedBehaviorSanitizer' \
|
||||
"$stderr_log" || true)
|
||||
if [[ -n "$filtered" ]]; then
|
||||
echo "FAIL: unexpected diagnostics from NULL input_path path:" >&2
|
||||
echo "$filtered" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "T-01 STDIN INPUT: CopyStat handled NULL input_path cleanly"
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stdout-skip test for T-01.
|
||||
#
|
||||
# PLAN.md §3.3 adds this guard in CloseFiles():
|
||||
# if (!rm_output && context->copy_stat && context->current_output_path) {
|
||||
# CopyStat(context->current_input_path, context->fout);
|
||||
# }
|
||||
# so that when current_output_path is NULL (stdout), CopyStat() is skipped
|
||||
# entirely — not even called with a NULL path. PLAN.md §5.6 prescribes
|
||||
# exercising this by running a compress|decompress pipe that uses stdout
|
||||
# on both sides.
|
||||
#
|
||||
# Success criteria:
|
||||
# - The pipe exits with status 0.
|
||||
# - The decompressed bytes on the final stdout equal the original input.
|
||||
# - No sanitizer/diagnostic output on stderr.
|
||||
#
|
||||
# Usage: test_copystat_stdout_skip.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
payload='hello brotli stdout pipeline'
|
||||
echo "$payload" > "$workdir/expected.txt"
|
||||
stderr_log="$workdir/stderr.log"
|
||||
|
||||
# Both stages use stdin/stdout (-c). On the compress side, context->fout is
|
||||
# bound to stdout and context->current_output_path should be NULL; same on
|
||||
# the decompress side. CopyStat must therefore be skipped on both.
|
||||
# We separate stderr so we can assert it is empty.
|
||||
result=$(
|
||||
echo "$payload" \
|
||||
| "$brotli_bin" -c 2>"$stderr_log" \
|
||||
| "$brotli_bin" -dc 2>>"$stderr_log"
|
||||
)
|
||||
|
||||
if [[ "$result" != "$payload" ]]; then
|
||||
echo "FAIL: stdout pipeline roundtrip did not preserve payload" >&2
|
||||
echo " got: $result" >&2
|
||||
echo " wanted: $payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The fix must not introduce spurious diagnostics on the stdout path.
|
||||
# Some sanitizers may print LeakSanitizer summaries; accept those only.
|
||||
if [[ -s "$stderr_log" ]]; then
|
||||
# Filter sanitizer chatter that is not a brotli diagnostic.
|
||||
filtered=$(grep -v -E \
|
||||
-e '^==[0-9]+==' \
|
||||
-e '^SUMMARY:' \
|
||||
-e 'AddressSanitizer' \
|
||||
-e 'LeakSanitizer' \
|
||||
-e 'UndefinedBehaviorSanitizer' \
|
||||
"$stderr_log" || true)
|
||||
if [[ -n "$filtered" ]]; then
|
||||
echo "FAIL: unexpected stderr output from stdout pipeline:" >&2
|
||||
echo "$filtered" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "T-01 STDOUT SKIP: compress|decompress stdout pipeline clean"
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Negative regression test for T-01 (google/brotli#1461).
|
||||
#
|
||||
# Wraps repro_copystat_swap.sh and INVERTS the success condition.
|
||||
#
|
||||
# Before the fix: the inner repro script exits 0 and flips target.txt mode
|
||||
# from 0600 -> 0644 via a post-close symlink swap.
|
||||
# After the fix: CopyStat() runs BEFORE fclose(), uses fchmod() on the still-
|
||||
# open fd, and can no longer be redirected at a swapped pathname. The target
|
||||
# file mode must remain 0600, and the inner script must therefore exit non-
|
||||
# zero. A non-zero exit from the inner script is what we want.
|
||||
#
|
||||
# Usage: test_copystat_swap_fixed.sh /path/to/brotli /path/to/libfclose_swap.so
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "usage: $0 /path/to/brotli /path/to/libfclose_swap.so" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
if "$script_dir/repro_copystat_swap.sh" "$@" >/dev/null 2>&1; then
|
||||
echo "REGRESSION: T-01 post-close CopyStat TOCTOU is reproducible again" >&2
|
||||
echo " The attacker still got target.txt mode flipped to 0644." >&2
|
||||
echo " The fix in CloseFiles() / CopyStat() has regressed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 FIXED: CopyStat no longer follows post-close symlink swap"
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Strict negative regression test for T-01.
|
||||
#
|
||||
# test_copystat_swap_fixed.sh simply inverts repro_copystat_swap.sh's exit
|
||||
# status. That inversion conflates two failure modes inside the repro:
|
||||
#
|
||||
# (a) The symlink WAS placed but target mode did NOT flip — THE FIX WORKS.
|
||||
# (b) The symlink was never placed — the LD_PRELOAD helper didn't run,
|
||||
# meaning the TEST INFRASTRUCTURE failed, not the fix.
|
||||
#
|
||||
# Both make the repro exit non-zero, but only (a) means the vulnerability
|
||||
# is fixed. This strict test replicates the repro setup inline so it can
|
||||
# distinguish (a) from (b):
|
||||
#
|
||||
# - Pass: symlink placed AND target mode is still 0600.
|
||||
# - Fail (regression): symlink placed AND target mode flipped to 0644.
|
||||
# - Fail (infra): symlink never placed — the test cannot conclude
|
||||
# anything about the fix.
|
||||
#
|
||||
# Usage: test_copystat_swap_fixed_strict.sh /path/to/brotli /path/to/libfclose_swap.so
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "usage: $0 /path/to/brotli /path/to/libfclose_swap.so" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
swap_so=$(readlink -f -- "$2")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
ld_preload="$swap_so"
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$script_dir/plain.bin.br" \
|
||||
"$script_dir/target.txt" "$workdir"/
|
||||
chmod 0644 "$workdir/plain.bin"
|
||||
chmod 0644 "$workdir/plain.bin.br"
|
||||
chmod 0600 "$workdir/target.txt"
|
||||
|
||||
out_path="$workdir/out"
|
||||
target_path="$workdir/target.txt"
|
||||
pre_target_hash=$(sha256sum "$target_path" | awk '{print $1}')
|
||||
|
||||
if ldd "$brotli_bin" | grep -q 'libasan'; then
|
||||
asan_runtime=$(cc -print-file-name=libasan.so)
|
||||
if [[ -f "$asan_runtime" ]]; then
|
||||
ld_preload="$asan_runtime:$ld_preload"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${LD_PRELOAD:-}" ]]; then
|
||||
ld_preload="$ld_preload:$LD_PRELOAD"
|
||||
fi
|
||||
|
||||
env \
|
||||
BROTLI_SWAP_OUTPUT_ABS="$out_path" \
|
||||
BROTLI_SWAP_TARGET_ABS="$target_path" \
|
||||
LD_PRELOAD="$ld_preload" \
|
||||
"$brotli_bin" -d -o "$out_path" "$workdir/plain.bin.br" \
|
||||
|| true # brotli may exit non-zero when it sees the symlinked output
|
||||
|
||||
# Infra check: the helper must have run and placed the symlink. If not,
|
||||
# the conclusion is ambiguous.
|
||||
#
|
||||
# The helper fires on fclose() of the output fd. Under the fix, CopyStat()
|
||||
# runs BEFORE fclose(), which is fine — fclose() is still called, and the
|
||||
# helper still swaps the path after that fclose() returns. So we expect
|
||||
# the symlink to be placed regardless of whether the fix is in effect.
|
||||
if [[ ! -L "$out_path" ]]; then
|
||||
echo "INFRA FAIL: symlink was never placed at $out_path" >&2
|
||||
echo " The LD_PRELOAD fclose_swap helper did not fire." >&2
|
||||
echo " Can't conclude whether the fix works. Check LD_PRELOAD, ASan" \
|
||||
"interaction, and helper build." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Real check: target mode must still be 0600.
|
||||
post_mode=$(stat -c '%a' "$target_path")
|
||||
post_target_hash=$(sha256sum "$target_path" | awk '{print $1}')
|
||||
|
||||
if [[ "$post_mode" == "644" ]]; then
|
||||
echo "REGRESSION: target mode flipped 0600 -> 0644 via post-close" \
|
||||
"CopyStat path" >&2
|
||||
echo " The T-01 TOCTOU is reproducible — the fix has regressed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$post_mode" != "600" ]]; then
|
||||
echo "REGRESSION (unexpected): target mode is $post_mode (expected 600)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$pre_target_hash" != "$post_target_hash" ]]; then
|
||||
echo "REGRESSION: target content changed (hash differs)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 FIXED STRICT: swap was placed but target remained 0600 unchanged"
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Swap-under--n test for T-01.
|
||||
#
|
||||
# Belt-and-suspenders check: even under the LD_PRELOAD fclose-swap attack,
|
||||
# passing -n (which skips CopyStat entirely) must leave target.txt's mode
|
||||
# unchanged. This test runs the swap repro with -n injected into the brotli
|
||||
# command line — CopyStat never runs, so the symlink is irrelevant and the
|
||||
# target's mode must stay 0600.
|
||||
#
|
||||
# This is a defense-in-depth test: if a future change were to wire
|
||||
# CopyStat() up on a code path that ignored copy_stat, this test would
|
||||
# catch it even if the primary fix is intact.
|
||||
#
|
||||
# We inline the attack setup here instead of reusing repro_copystat_swap.sh
|
||||
# because that script hard-codes the brotli arg list. The same helper
|
||||
# (libfclose_swap.so) is reused as-is.
|
||||
#
|
||||
# Usage: test_copystat_swap_with_no_copy_stat.sh /path/to/brotli /path/to/libfclose_swap.so
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "usage: $0 /path/to/brotli /path/to/libfclose_swap.so" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
swap_so=$(readlink -f -- "$2")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
ld_preload="$swap_so"
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$script_dir/plain.bin.br" \
|
||||
"$script_dir/target.txt" "$workdir"/
|
||||
chmod 0644 "$workdir/plain.bin"
|
||||
chmod 0644 "$workdir/plain.bin.br"
|
||||
chmod 0600 "$workdir/target.txt"
|
||||
|
||||
out_path="$workdir/out"
|
||||
target_path="$workdir/target.txt"
|
||||
|
||||
if ldd "$brotli_bin" | grep -q 'libasan'; then
|
||||
asan_runtime=$(cc -print-file-name=libasan.so)
|
||||
if [[ -f "$asan_runtime" ]]; then
|
||||
ld_preload="$asan_runtime:$ld_preload"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${LD_PRELOAD:-}" ]]; then
|
||||
ld_preload="$ld_preload:$LD_PRELOAD"
|
||||
fi
|
||||
|
||||
env \
|
||||
BROTLI_SWAP_OUTPUT_ABS="$out_path" \
|
||||
BROTLI_SWAP_TARGET_ABS="$target_path" \
|
||||
LD_PRELOAD="$ld_preload" \
|
||||
"$brotli_bin" -d -n -o "$out_path" "$workdir/plain.bin.br" || true
|
||||
|
||||
# With -n, CopyStat never runs, so target.txt must remain 0600.
|
||||
mode=$(stat -c '%a' "$target_path")
|
||||
if [[ "$mode" != "600" ]]; then
|
||||
echo "FAIL: target mode changed to $mode under -n; CopyStat was called" \
|
||||
"despite copy_stat==BROTLI_FALSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bonus: the content must also be unchanged.
|
||||
if ! cmp -s -- "$workdir/target.txt" "$script_dir/target.txt"; then
|
||||
echo "FAIL: target content changed under -n attack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 SWAP+NO-COPY-STAT: target unchanged under attack with -n"
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Timestamp-copy test for T-01.
|
||||
#
|
||||
# PLAN.md §3.2 replaces path-based utime() with fd-based futimens() under
|
||||
# HAVE_UTIMENSAT. On Linux we always have HAVE_UTIMENSAT, so this test
|
||||
# validates futimens() is actually invoked with the input's mtime.
|
||||
#
|
||||
# Procedure:
|
||||
# 1. Create an input file and set its mtime to a well-known past date.
|
||||
# 2. Sleep briefly so the ambient "now" drifts from that mtime.
|
||||
# 3. Compress. The compressed output should have the same mtime as the
|
||||
# input (give or take filesystem-timestamp resolution).
|
||||
# 4. Decompress to a fresh path. The decompressed output should also
|
||||
# carry the input's mtime (actually: the compressed file's mtime,
|
||||
# which is the input's mtime from step 3).
|
||||
#
|
||||
# This guards against:
|
||||
# - CopyTimeStat refactor dropping the call entirely.
|
||||
# - The futimens path using wrong indices or wrong struct fields.
|
||||
#
|
||||
# Usage: test_copystat_timestamp.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 0022
|
||||
|
||||
cp -- "$script_dir/plain.bin" "$workdir/in.bin"
|
||||
# Pinned mtime in the past (well before the current CI run).
|
||||
past="202001020304.05"
|
||||
touch -m -t "$past" "$workdir/in.bin"
|
||||
expected_mtime=$(stat -c '%Y' "$workdir/in.bin")
|
||||
|
||||
# Small drift to ensure "now" != expected_mtime in the default-create case.
|
||||
sleep 1
|
||||
|
||||
"$brotli_bin" -fk "$workdir/in.bin" -o "$workdir/in.bin.br"
|
||||
compressed_mtime=$(stat -c '%Y' "$workdir/in.bin.br")
|
||||
|
||||
if [[ "$compressed_mtime" != "$expected_mtime" ]]; then
|
||||
echo "FAIL: compressed mtime ($compressed_mtime) != input mtime" \
|
||||
"($expected_mtime)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$brotli_bin" -df "$workdir/in.bin.br" -o "$workdir/out.bin"
|
||||
decompressed_mtime=$(stat -c '%Y' "$workdir/out.bin")
|
||||
|
||||
if [[ "$decompressed_mtime" != "$expected_mtime" ]]; then
|
||||
echo "FAIL: decompressed mtime ($decompressed_mtime) != input mtime" \
|
||||
"($expected_mtime)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 TIMESTAMP: futimens copied mtime through compress+decompress"
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mode-matrix test for T-01. Exercises fchmod over the 9 permission bits
|
||||
# (S_IRWXU | S_IRWXG | S_IRWXO) that CopyStat() is specified to copy.
|
||||
#
|
||||
# For each canonical mode below, set the input file mode, compress, and
|
||||
# verify the compressed output carries the same mode. Also decompress and
|
||||
# verify the decompressed output carries the same mode.
|
||||
#
|
||||
# The mode mask documented in PLAN.md §3.1 is:
|
||||
# statbuf.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO)
|
||||
# i.e. only the low 9 bits. Any setuid/setgid/sticky bits (02000, 04000,
|
||||
# 01000) MUST NOT leak through — that is tested separately in
|
||||
# test_copystat_mode_mask.sh.
|
||||
#
|
||||
# Usage: test_copystat_various_modes.sh /path/to/brotli
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 /path/to/brotli" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
brotli_bin=$(readlink -f -- "$1")
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 0022
|
||||
|
||||
# Representative modes: owner-only, group-readable, world-readable,
|
||||
# world-readable-exec, and asymmetric combinations that round-trip cleanly
|
||||
# through fchmod() but would be corrupted by any accidental umask masking.
|
||||
modes=(
|
||||
"600"
|
||||
"640"
|
||||
"644"
|
||||
"664"
|
||||
"660"
|
||||
"400"
|
||||
"444"
|
||||
"755"
|
||||
"700"
|
||||
"604"
|
||||
)
|
||||
|
||||
failures=0
|
||||
for mode in "${modes[@]}"; do
|
||||
sub=$(mktemp -d -p "$workdir")
|
||||
cp -- "$script_dir/plain.bin" "$sub/in.bin"
|
||||
chmod "$mode" "$sub/in.bin"
|
||||
|
||||
"$brotli_bin" -fk "$sub/in.bin" -o "$sub/in.bin.br"
|
||||
compressed_mode=$(stat -c '%a' "$sub/in.bin.br")
|
||||
|
||||
"$brotli_bin" -df "$sub/in.bin.br" -o "$sub/out.bin"
|
||||
decompressed_mode=$(stat -c '%a' "$sub/out.bin")
|
||||
|
||||
if [[ "$compressed_mode" != "$mode" ]]; then
|
||||
echo "FAIL mode=$mode: compress produced $compressed_mode" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$decompressed_mode" != "$mode" ]]; then
|
||||
echo "FAIL mode=$mode: decompress produced $decompressed_mode" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $failures -ne 0 ]]; then
|
||||
echo "T-01 MODE MATRIX: $failures failures across ${#modes[@]} modes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "T-01 MODE MATRIX: all ${#modes[@]} modes propagated correctly"
|
||||
Reference in New Issue
Block a user