#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # Extraction confinement and atomic-output regression tests. set -Eeuo pipefail REPO_ROOT=$(pwd -P) ZUPT_BIN=${1:-$REPO_ROOT/zupt} case $ZUPT_BIN in /*) ;; *) ZUPT_BIN=$REPO_ROOT/${ZUPT_BIN#./} ;; esac TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/zupt-path-traversal.XXXXXX") cleanup() { local status=$? chmod -R u+rwX "$TEST_ROOT" 2>/dev/null || true rm -rf -- "$TEST_ROOT" exit "$status" } trap cleanup EXIT trap 'exit 130' HUP INT TERM PASS=0 FAIL=0 SKIP=0 pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); } fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); } skip() { printf ' SKIP: %s\n' "$1"; SKIP=$((SKIP + 1)); } WINDOWS_NATIVE=0 case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) WINDOWS_NATIVE=1 ;; esac FIXTURE=$TEST_ROOT/archive-path-fixture # Compiler and linker flag variables intentionally expand into argument lists, # matching the Makefile command-line contract. # shellcheck disable=SC2086 "${CC:-cc}" ${CPPFLAGS:-} ${CFLAGS:-} -std=c11 -I"$REPO_ROOT/include" \ "$REPO_ROOT/tests/archive_path_fixture.c" "$REPO_ROOT/src/zupt_xxh.c" \ ${LDFLAGS:-} ${LDLIBS:-} -o "$FIXTURE" make_fixture() { MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$1" "--entry=$2" # Prove that the archive passed header, trailer, index-block, index checksum, # decompression, and index parsing before using it as a negative fixture. "$ZUPT_BIN" list "$1" > "$TEST_ROOT/list.log" 2>&1 grep -F -- "$2" "$TEST_ROOT/list.log" >/dev/null } expect_unsafe_path() { local label=$1 archive=$2 entry=$3 output=$4 log=$TEST_ROOT/extract.log rc make_fixture "$archive" "$entry" mkdir -p "$output" set +e "$ZUPT_BIN" extract -o "$output" "$archive" > "$log" 2>&1 rc=$? set -e if ((rc != 0)) && grep -F 'rejected unsafe path' "$log" >/dev/null; then pass "$label" else fail "$label" fi } cd "$TEST_ROOT" expect_unsafe_path 'relative .. entry is rejected after a valid index parse' \ "$TEST_ROOT/relative.zupt" '../escaped.txt' "$TEST_ROOT/relative-out" [[ ! -e $TEST_ROOT/escaped.txt ]] || fail 'relative traversal wrote outside root' ABSOLUTE_TARGET=$TEST_ROOT/absolute-owned expect_unsafe_path 'absolute entry is rejected after a valid index parse' \ "$TEST_ROOT/absolute.zupt" "$ABSOLUTE_TARGET" "$TEST_ROOT/absolute-out" if [[ ! -e $ABSOLUTE_TARGET ]]; then pass 'absolute target remains absent' else fail 'absolute target remains absent' fi for case_data in \ 'trailing-space|dir/.. ' \ 'alternate-data-stream|dir/name:stream' \ 'reserved-device|dir/CON' \ 'reserved-device-extension|dir/LPT1.txt' \ 'trailing-dot|dir/file.'; do label=${case_data%%|*} entry=${case_data#*|} expect_unsafe_path "Windows-normalized $label path is rejected" \ "$TEST_ROOT/$label.zupt" "$entry" "$TEST_ROOT/$label-out" done control_entry=$'safe\033[31mRED\033[0m.txt' MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$TEST_ROOT/control.zupt" \ "--entry=$control_entry" set +e "$ZUPT_BIN" list "$TEST_ROOT/control.zupt" \ > "$TEST_ROOT/control.log" 2>&1 control_status=$? set -e if ((control_status != 0)) && ! grep -q $'\033' "$TEST_ROOT/control.log"; then pass 'control-byte archive path is rejected without terminal injection' else fail 'control-byte archive path is rejected without terminal injection' fi expect_display_unsafe_path_rejected() { local label=$1 name=$2 entry=$3 local archive=$TEST_ROOT/$name.zupt log=$TEST_ROOT/$name.log status MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$archive" "--entry=$entry" set +e "$ZUPT_BIN" list "$archive" > "$log" 2>&1 status=$? set -e if ((status != 0)) && ! LC_ALL=C grep -Fq -- "$entry" "$log"; then pass "$label" else fail "$label" fi } expect_display_unsafe_path_rejected \ 'raw C1 archive path is rejected without terminal injection' \ raw-c1 $'safe\23331m.txt' expect_display_unsafe_path_rejected \ 'UTF-8 C1 archive path is rejected without terminal injection' \ utf8-c1 $'safe\302\23331m.txt' expect_display_unsafe_path_rejected \ 'Unicode bidi-control archive path is rejected without display spoofing' \ bidi $'safe\342\200\256exe.txt' expect_display_unsafe_path_rejected \ 'invalid UTF-8 archive path is rejected without raw display' \ invalid-utf8 $'safe\300\257.txt' make_fixture "$TEST_ROOT/leaf.zupt" 'innocent.txt' printf '%s\n' DO_NOT_OVERWRITE > "$TEST_ROOT/sentinel" mkdir "$TEST_ROOT/leaf-out" if ln -s "$TEST_ROOT/sentinel" "$TEST_ROOT/leaf-out/innocent.txt" \ 2>/dev/null && [[ -L $TEST_ROOT/leaf-out/innocent.txt ]]; then if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/leaf-out" "$TEST_ROOT/leaf.zupt" \ > "$TEST_ROOT/leaf.log" 2>&1 && [[ $(<"$TEST_ROOT/sentinel") == DO_NOT_OVERWRITE ]]; then pass 'leaf symlink is refused without changing its target' else fail 'leaf symlink is refused without changing its target' fi else skip 'leaf symlink test is unsupported by this runner' fi mkdir "$TEST_ROOT/regular-out" printf '%s\n' EXISTING > "$TEST_ROOT/regular-out/innocent.txt" if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/regular-out" "$TEST_ROOT/leaf.zupt" \ > "$TEST_ROOT/regular.log" 2>&1 && [[ $(<"$TEST_ROOT/regular-out/innocent.txt") == EXISTING ]]; then pass 'existing regular file is never overwritten' else fail 'existing regular file is never overwritten' fi mkdir "$TEST_ROOT/hardlink-out" printf '%s\n' HARDLINK_SENTINEL > "$TEST_ROOT/hardlink-target" if ln "$TEST_ROOT/hardlink-target" "$TEST_ROOT/hardlink-out/innocent.txt" 2>/dev/null; then if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/hardlink-out" "$TEST_ROOT/leaf.zupt" \ > "$TEST_ROOT/hardlink.log" 2>&1 && [[ $(<"$TEST_ROOT/hardlink-target") == HARDLINK_SENTINEL ]]; then pass 'existing hardlink is never overwritten' else fail 'existing hardlink is never overwritten' fi else skip 'hardlink test is unsupported by the temporary filesystem' fi make_fixture "$TEST_ROOT/parent.zupt" 'nested/file.txt' mkdir "$TEST_ROOT/parent-out" "$TEST_ROOT/parent-outside" if ln -s "$TEST_ROOT/parent-outside" "$TEST_ROOT/parent-out/nested" \ 2>/dev/null && [[ -L $TEST_ROOT/parent-out/nested ]]; then if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/parent-out" "$TEST_ROOT/parent.zupt" \ > "$TEST_ROOT/parent.log" 2>&1 && [[ -z $(find "$TEST_ROOT/parent-outside" -mindepth 1 -print -quit) ]]; then pass 'intermediate symlink cannot redirect extraction or directory creation' else fail 'intermediate symlink cannot redirect extraction or directory creation' fi else skip 'intermediate symlink test is unsupported by this runner' fi mkdir "$TEST_ROOT/root-outside" if ln -s "$TEST_ROOT/root-outside" "$TEST_ROOT/root-link" 2>/dev/null && [[ -L $TEST_ROOT/root-link ]]; then if ((WINDOWS_NATIVE)); then if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \ "$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 && [[ -z $(find "$TEST_ROOT/root-outside" -mindepth 1 -print -quit) ]]; then pass 'Windows rejects a reparse-point output-root ancestor' else fail 'Windows rejects a reparse-point output-root ancestor' fi elif "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \ "$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 && [[ $(<"$TEST_ROOT/root-outside/child/innocent.txt") == 'fixture content' ]]; then pass 'user-selected POSIX output-root symlink is resolved once' else fail 'user-selected POSIX output-root symlink is resolved once' fi else skip 'output-root symlink test is unsupported by this runner' fi make_fixture "$TEST_ROOT/backslash.zupt" 'back\slash.txt' mkdir "$TEST_ROOT/backslash-out" if "$ZUPT_BIN" extract -o "$TEST_ROOT/backslash-out" "$TEST_ROOT/backslash.zupt" \ > "$TEST_ROOT/backslash.log" 2>&1 && [[ -f $TEST_ROOT/backslash-out/back/slash.txt ]]; then pass 'backslash separators are normalized within the extraction root' else fail 'backslash separators are normalized within the extraction root' fi make_fixture "$TEST_ROOT/legitimate.zupt" 'safe dir/ação.txt' mkdir "$TEST_ROOT/legitimate-out" if "$ZUPT_BIN" extract -o "$TEST_ROOT/legitimate-out" \ "$TEST_ROOT/legitimate.zupt" > "$TEST_ROOT/legitimate.log" 2>&1 && [[ $(<"$TEST_ROOT/legitimate-out/safe dir/ação.txt") == 'fixture content' ]]; then pass 'safe nested UTF-8 path extracts normally' else fail 'safe nested UTF-8 path extracts normally' fi mkdir -p "$TEST_ROOT/relative-root/work" if (cd "$TEST_ROOT/relative-root/work" && "$ZUPT_BIN" extract -o ../restore "$TEST_ROOT/leaf.zupt" \ > "$TEST_ROOT/relative-root.log" 2>&1) && [[ -f $TEST_ROOT/relative-root/restore/innocent.txt ]]; then pass 'user-selected output root may contain a relative .. component' else fail 'user-selected output root may contain a relative .. component' fi cp "$TEST_ROOT/leaf.zupt" "$TEST_ROOT/corrupt.zupt" python3 - "$TEST_ROOT/corrupt.zupt" <<'PY' import pathlib import sys path = pathlib.Path(sys.argv[1]) data = bytearray(path.read_bytes()) # Header (64) + data-block fixed/varint header (17): first payload byte. data[81] ^= 0x01 path.write_bytes(data) PY mkdir "$TEST_ROOT/corrupt-out" if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/corrupt-out" "$TEST_ROOT/corrupt.zupt" \ > "$TEST_ROOT/corrupt.log" 2>&1 && [[ -z $(find "$TEST_ROOT/corrupt-out" -mindepth 1 -print -quit) ]]; then pass 'corrupt payload leaves neither a final nor temporary output file' else fail 'corrupt payload leaves neither a final nor temporary output file' fi printf '\n Path-confinement regression: %d PASS, %d FAIL, %d SKIP\n' \ "$PASS" "$FAIL" "$SKIP" ((FAIL == 0))