mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +01:00
All three were found by review and reproduced before fixing. Each let a run
report success while proving nothing -- the shape this harness exists to
prevent.
A reused prefix defeated dropped-write detection entirely. Run 1's files were
still there when run 2's manifest was taken, so the manifest reported writes
that never happened and the fixture pair diffed CLEAN. The doc's own examples
demonstrated that workflow. A non-empty prefix is now refused -- not deleted,
since the path is user-supplied -- and the examples use distinct prefixes.
A missing bwrap was swallowed. trace_prefix_bwrap_argv returned 1, but the
caller reads it through `mapfile < <(...)` and a process substitution's status
is discarded, so runner ended up empty and the run continued with no sandbox
and no uid 0. Same swallow as the grep-status one fixed earlier, one layer up.
bwrap is now probed in validate_requirements, before anything runs.
The absolute-path scan only fired when no --bind-abs was given at all, so
binding one path and missing three produced no warning. It now runs whenever
--prefix is given and reports only what is not already covered. Fixing that
surfaced a second defect: --bind-abs on a FILE aborted with "Can't mkdir
/etc/profile: Not a directory", and binding its parent reintroduced the
wholesale /etc shadowing this change had already measured as harmful ($SUDO_USER
collapsing to /home//, the real /etc/profile unreadable). Files are now bound
file-over-file, seeded with a copy so a script that greps before appending sees
real content.
Also measured rather than argued: the XDG redirect. A new fixture run with a
real XDG_DATA_HOME pointing at a decoy shows all four writes landing in the
prefix and the decoy empty -- the rsync --delete data-loss path, proven closed
without running install_leechblock.sh. And no-prefix output was diffed against
cdf2a55 from a detached worktree: byte-identical, so existing baselines hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
234 lines
7.4 KiB
Bash
Executable File
234 lines
7.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# ============================================================================
|
|
# Capture an execution trace of a shell script with every mutating command
|
|
# stubbed, so a split can be verified by RUNNING it rather than by sourcing it.
|
|
#
|
|
# Why this exists: verify_shell_split.sh sources the libs and never calls them,
|
|
# which proves `source` lines resolve and nothing more. The analyze_repo.sh
|
|
# split passed that, plus bash -n, shellcheck and a function-set diff, and
|
|
# still shipped a script that aborted after language detection -- a `set -e`
|
|
# function-tail return and a self-referencing nameref, neither reachable
|
|
# without execution. See docs/shell-split-verification.md.
|
|
#
|
|
# The trace is the artifact: run this at the pre-split commit and again after
|
|
# the split, then diff. An identical trace means the same commands ran in the
|
|
# same order with the same arguments.
|
|
#
|
|
# Mutating binaries are shadowed by stubs earlier on PATH, so the live system
|
|
# is never touched. That is what makes this safe for the enforcement and
|
|
# installer scripts Decision 6 forbids executing for real.
|
|
#
|
|
# Scripts whose job is PLACING FILES need --prefix: shell redirections cannot
|
|
# be stubbed via PATH, so without it the choice is an empty trace or a rewritten
|
|
# live system. See lib/trace_prefix.sh for the two layers that solves.
|
|
#
|
|
# Usage:
|
|
# trace_shell_split.sh <script> [-- <script args>]
|
|
# trace_shell_split.sh <script> --out <file> [-- <script args>]
|
|
# trace_shell_split.sh <script> --stub git,curl,makepkg --out <file>
|
|
# trace_shell_split.sh <script> --prefix <dir> [--bind-abs /etc/modprobe.d]
|
|
# ============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
readonly SCRIPT_NAME
|
|
|
|
# readlink -f, not dirname alone: this repo's root-level entry points are
|
|
# symlinks into meta/, and an unresolved SCRIPT_DIR turns a source line into an
|
|
# instant exit under set -e. See docs/shell-split-verification.md.
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
|
|
readonly SCRIPT_DIR
|
|
# shellcheck source=lib/trace_prefix.sh
|
|
source "$SCRIPT_DIR/lib/trace_prefix.sh"
|
|
# shellcheck source=lib/trace_stubs.sh
|
|
source "$SCRIPT_DIR/lib/trace_stubs.sh"
|
|
|
|
TARGET=""
|
|
OUT=""
|
|
TEMP_DIR=""
|
|
PREFIX=""
|
|
declare -a SCRIPT_ARGS=()
|
|
declare -a EXTRA_STUBS=()
|
|
declare -a BIND_ABS=()
|
|
|
|
cleanup() {
|
|
if [[ -n "${TEMP_DIR:-}" && -d "$TEMP_DIR" ]]; then
|
|
rm -rf "$TEMP_DIR"
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
|
|
usage() {
|
|
echo "Usage: $SCRIPT_NAME <script> [--out <file>] [--stub a,b] [-- <script args>]"
|
|
echo " <script> script to trace (not modified)"
|
|
echo " --out <file> write the trace here (default: stdout)"
|
|
echo " --stub a,b also shadow these binaries (e.g. git,curl,makepkg)"
|
|
echo " --prefix <d> redirect HOME/XDG writes into <d> and list them"
|
|
echo " --bind-abs <p> bind absolute path <p> into the prefix (repeatable)"
|
|
echo " -- everything after this is passed to the script"
|
|
exit 0
|
|
}
|
|
|
|
validate_requirements() {
|
|
if [[ -z "$TARGET" ]]; then
|
|
echo "Error: no script given" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! -f "$TARGET" ]]; then
|
|
echo "Error: no such script: $TARGET" >&2
|
|
exit 1
|
|
fi
|
|
# A prefix must start empty. Reusing one across a before/after pair leaves
|
|
# run 1's files in place, so run 2's manifest reports writes it never made
|
|
# and a DROPPED WRITE STOPS SHOWING UP -- measured: the fixture pair diffs
|
|
# clean on a reused prefix. Refuse rather than deleting a user-supplied
|
|
# path.
|
|
if [[ -n "$PREFIX" && -d "$PREFIX" ]] && [[ -n "$(ls -A "$PREFIX" 2>/dev/null)" ]]; then
|
|
echo "Error: --prefix $PREFIX is not empty; use a fresh directory" >&2
|
|
echo " (a stale file makes the manifest report a write that did not happen)" >&2
|
|
exit 1
|
|
fi
|
|
# bwrap is probed HERE, not where the argv is built: there the failure is
|
|
# returned through a process substitution, whose status mapfile discards,
|
|
# so a missing bwrap left runner empty and the run continued with no
|
|
# sandbox and no uid 0 -- the exact fail-open this flag exists to prevent.
|
|
if [[ ${#BIND_ABS[@]} -gt 0 ]] && ! command -v bwrap >/dev/null 2>&1; then
|
|
echo "Error: --bind-abs needs bubblewrap (pacman -S bubblewrap)" >&2
|
|
exit 1
|
|
fi
|
|
# Refuse rather than quietly writing to the real path: a script with
|
|
# hardcoded absolute destinations and no --bind-abs would either mutate the
|
|
# live system or take a different branch on EPERM, and both look like a
|
|
# clean trace.
|
|
#
|
|
# The scan runs whenever --prefix is given, not only when BIND_ABS is empty.
|
|
# Gating it on "no binds at all" meant a user who bound one path and missed
|
|
# three got no warning: those writes fail closed, but the failure surfaces
|
|
# only as a stderr line inside a long trace, which is easy to read as the
|
|
# script's own behaviour.
|
|
if [[ -n "$PREFIX" ]]; then
|
|
local -a found=() unbound=()
|
|
local path bound covered
|
|
mapfile -t found < <(trace_prefix_scan_absolute "$TARGET")
|
|
for path in ${found[@]+"${found[@]}"}; do
|
|
covered=0
|
|
for bound in ${BIND_ABS[@]+"${BIND_ABS[@]}"}; do
|
|
# A bind of /etc/X11 covers /etc/X11/xorg.conf.d beneath it.
|
|
if [[ $path == "$bound" || $path == "$bound"/* ]]; then
|
|
covered=1
|
|
break
|
|
fi
|
|
done
|
|
((covered)) || unbound+=("$path")
|
|
done
|
|
if [[ ${#unbound[@]} -gt 0 ]]; then
|
|
echo "Error: $TARGET writes to absolute paths; pass --bind-abs for each:" >&2
|
|
printf ' --bind-abs %s\n' "${unbound[@]}" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
validate_requirements
|
|
|
|
TEMP_DIR="$(mktemp -d)"
|
|
if [[ ! -d "$TEMP_DIR" ]]; then
|
|
echo "Error: failed to create temporary directory" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local bin_dir="$TEMP_DIR/bin"
|
|
local trace="$TEMP_DIR/trace.txt"
|
|
: >"$trace"
|
|
write_stubs "$bin_dir" ${EXTRA_STUBS[@]+"${EXTRA_STUBS[@]}"}
|
|
|
|
# xtrace to a dedicated fd so the script's own stdout stays separate.
|
|
local xtrace="$TEMP_DIR/xtrace.txt"
|
|
|
|
# Under --prefix the run happens inside bwrap when absolute destinations
|
|
# were bound; otherwise it is a plain subshell with a redirected HOME.
|
|
local -a runner=()
|
|
if [[ -n "$PREFIX" && ${#BIND_ABS[@]} -gt 0 ]]; then
|
|
mapfile -t runner < <(trace_prefix_bwrap_argv "$PREFIX" "${BIND_ABS[@]}" --)
|
|
fi
|
|
|
|
set +e
|
|
(
|
|
export TRACE_FILE="$trace"
|
|
export PATH="$bin_dir:$PATH"
|
|
if [[ -n "$PREFIX" ]]; then
|
|
local assignment
|
|
while IFS= read -r assignment; do
|
|
export "${assignment?}"
|
|
done < <(trace_prefix_env "$PREFIX")
|
|
fi
|
|
exec 9>"$xtrace"
|
|
export BASH_XTRACEFD=9
|
|
set -x
|
|
"${runner[@]+"${runner[@]}"}" bash "$TARGET" "${SCRIPT_ARGS[@]+"${SCRIPT_ARGS[@]}"}"
|
|
) >"$TEMP_DIR/stdout.txt" 2>"$TEMP_DIR/stderr.txt"
|
|
local status=$?
|
|
set -e
|
|
|
|
{
|
|
echo "=== exit status: $status"
|
|
echo "=== mutating calls (stubbed)"
|
|
cat "$trace"
|
|
# Emitted only under --prefix, so traces taken without the flag stay
|
|
# byte-identical to the ones already captured.
|
|
if [[ -n "$PREFIX" ]]; then
|
|
echo "=== files written (prefix)"
|
|
trace_prefix_manifest "$PREFIX"
|
|
fi
|
|
echo "=== stdout"
|
|
sed "s#${PREFIX:-__no_prefix__}#@PREFIX@#g" "$TEMP_DIR/stdout.txt"
|
|
echo "=== stderr"
|
|
sed "s#${PREFIX:-__no_prefix__}#@PREFIX@#g" "$TEMP_DIR/stderr.txt"
|
|
} >"${OUT:-/dev/stdout}"
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--out)
|
|
OUT="$2"
|
|
shift 2
|
|
;;
|
|
--stub)
|
|
# Comma-separated extra binaries to shadow for this run only.
|
|
IFS=',' read -r -a EXTRA_STUBS <<<"$2"
|
|
shift 2
|
|
;;
|
|
--prefix)
|
|
PREFIX="$2"
|
|
shift 2
|
|
;;
|
|
--bind-abs)
|
|
BIND_ABS+=("$2")
|
|
shift 2
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
;;
|
|
--)
|
|
shift
|
|
SCRIPT_ARGS=("$@")
|
|
break
|
|
;;
|
|
*)
|
|
if [[ -z "$TARGET" ]]; then
|
|
TARGET="$1"
|
|
shift
|
|
else
|
|
echo "Unknown option: $1" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
main "$@"
|