diff --git a/scripts/memlab.sh b/scripts/memlab.sh index 373742556..4e441723a 100644 --- a/scripts/memlab.sh +++ b/scripts/memlab.sh @@ -24,6 +24,16 @@ if [ ! -x "$BINARY" ]; then exit 2 fi +# The profiled process must reach a daemon rendezvous and cache this run owns: +# only CBM_RUNTIME_DIR moves the rendezvous, so a private CBM_CACHE_DIR alone +# joined the operator's account daemon (#1691, #1696). The helper also stops +# that daemon before its cache is removed and leaves the root for diagnosis +# when it will not stop. +# shellcheck source=test-runtime.sh +source "$(dirname "$0")/test-runtime.sh" +cbm_test_runtime_init || exit 1 +trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT + WORK=$(mktemp -d 2>/dev/null || mktemp -d -t memlab) # Native Windows: the server walks the full ancestor chain of both the binary @@ -75,7 +85,12 @@ PROFILE_OUT="$PWD/memlab-${LABEL}.jsonl" RUN_LOG="$PWD/memlab-${LABEL}.log" rm -f "$PROFILE_OUT" "$RUN_LOG" -cleanup() { rm -rf "$WORK" 2>/dev/null || true; rm -rf "${WIN_ROOT:-}" 2>/dev/null || true; } +cleanup() { + # The helper probes with $BINARY, whose native-Windows copy lives in $WORK. + cbm_test_runtime_cleanup "$BINARY" + rm -rf "$WORK" 2>/dev/null || true + rm -rf "${WIN_ROOT:-}" 2>/dev/null || true +} trap cleanup EXIT # A fixed corpus: same file count and content on every platform, so a @@ -108,12 +123,15 @@ done echo "=== memlab: binary=$BINARY requests=$REQUESTS label=$LABEL ===" echo "corpus: $(find "$CORPUS" -name '*.py' | wc -l | tr -d ' ') files" -# The Windows binary needs a native path here; an msys /c/... path is not one. -if command -v cygpath >/dev/null 2>&1 && ! command -v winepath >/dev/null 2>&1; then +# The native Windows binary needs a native path here, under its stamped root; +# an msys /c/... path is not one. Everywhere else the helper's owner-only cache +# (already exported as CBM_CACHE_DIR) is the cache. +if [ -n "${WIN_ROOT:-}" ]; then mkdir -p "$WORK/cache" export CBM_CACHE_DIR="$(cygpath -w "$WORK/cache")" + CACHE_HOST="$WORK/cache" else - export CBM_CACHE_DIR="$WORK/cache" + CACHE_HOST="$CBM_TEST_CACHE_DIR_HOST" fi export CBM_MEM_PROFILE=1 export CBM_MEM_PROFILE_OUT="$PROFILE_OUT" @@ -139,7 +157,7 @@ python3 "$(dirname "$0")/memlab-drive.py" "$DRIVE_BINARY" "$DRIVE_CORPUS" "$REQU RC=$? # With CBM_CACHE_DIR set the process logs to its own file rather than stderr, # so fold that in or the census series is invisible. -cat "$WORK"/cache/logs/*.log >> "$RUN_LOG" 2>/dev/null || true +cat "$CACHE_HOST"/logs/*.log >> "$RUN_LOG" 2>/dev/null || true cat "$WORK/server-stderr.log" >> "$RUN_LOG" 2>/dev/null || true RESPONSES=$(sed -n "s/.*served=\\([0-9]*\\).*/\\1/p" "$WORK/drive.out" | head -1); RESPONSES=${RESPONSES:-0} CENSUS=$(grep -c "mem.census" "$RUN_LOG" 2>/dev/null | head -1); CENSUS=${CENSUS:-0} diff --git a/scripts/test.sh b/scripts/test.sh index ecfc26998..24902b39a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -227,6 +227,9 @@ bash "$ROOT/tests/test_makefile_ts_runtime_dependencies.sh" echo "=== Step 0g: security fuzz harness self-test ===" bash "$ROOT/tests/test_security_fuzz_harness.sh" +echo "=== Step 0g2: memlab harness runtime isolation contract (#1696) ===" +bash "$ROOT/tests/test_memlab_runtime_isolation_contract.sh" + echo "=== Step 0h: smoke release-fixture contract ===" bash "$ROOT/tests/test_smoke_fixture_contract.sh" diff --git a/tests/test_memlab_runtime_isolation_contract.sh b/tests/test_memlab_runtime_isolation_contract.sh new file mode 100755 index 000000000..f414c41c5 --- /dev/null +++ b/tests/test_memlab_runtime_isolation_contract.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runtime-isolation contract for the memlab harness (#1696, follow-up to #1691). +# +# scripts/memlab.sh starts the product over stdio to attribute retained memory +# and then removes its work directory. It used to give the run a private +# CBM_CACHE_DIR only, but only CBM_RUNTIME_DIR moves the daemon rendezvous +# (docs/CONFIGURATION.md), so the profiled process joined the operator's +# account daemon — refused with a cache-root conflict when one was live, or +# left as the account daemon with its cache deleted underneath it otherwise. +# +# memlab launches the product through its Python driver, and the driver hands +# the product the environment it inherited (memlab-drive.py passes no env= to +# Popen). Recording the environment at the driver boundary therefore observes +# exactly what the product receives, on every host — including Windows, whose +# native Python cannot exec a shell fixture — so a python3 shim stands in for +# the driver and no product process is started at all. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +normalize_path() { + local path=${1%$'\r'} + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$path" 2>/dev/null && return 0 + fi + printf '%s\n' "${path//\\//}" +} + +if grep -q 'env=' "$ROOT/scripts/memlab-drive.py"; then + fail "memlab-drive.py no longer passes the harness environment through unchanged" +fi + +SHIM_DIR="$WORKDIR/bin" +mkdir -p "$SHIM_DIR" +cat > "$SHIM_DIR/python3" <<'EOF' +#!/usr/bin/env bash +printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_MEMLAB_ENV_PROBE" +echo "served=0 failed=0" +exit 1 +EOF +DUMMY_BINARY="$WORKDIR/dummy-binary" +printf '#!/usr/bin/env bash\nexit 0\n' > "$DUMMY_BINARY" +chmod +x "$SHIM_DIR/python3" "$DUMMY_BINARY" + +CALLER_CACHE="$WORKDIR/caller-cache" +CALLER_RUNTIME="$WORKDIR/caller-runtime" +ENV_LOG="$WORKDIR/environment.log" +mkdir -p "$CALLER_CACHE" "$CALLER_RUNTIME" "$WORKDIR/cwd" + +# memlab writes its profile and log into $PWD, hence the cwd change. It is +# tracked without an executable bit, so run it through bash. +( + cd "$WORKDIR/cwd" + PATH="$SHIM_DIR:$PATH" \ + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ + CBM_MEMLAB_ENV_PROBE="$ENV_LOG" \ + bash "$ROOT/scripts/memlab.sh" "$DUMMY_BINARY" 1 probe > "$WORKDIR/memlab.out" 2>&1 || true +) + +[[ -s "$ENV_LOG" ]] || fail "memlab did not reach its driver" + +CALLER_CACHE_NORMALIZED=$(normalize_path "$CALLER_CACHE") +CALLER_RUNTIME_NORMALIZED=$(normalize_path "$CALLER_RUNTIME") +private_root="" +while IFS=$'\t' read -r child_cache_raw child_runtime_raw; do + child_cache=$(normalize_path "$child_cache_raw") + child_runtime=$(normalize_path "$child_runtime_raw") + if [[ -z "$child_runtime" || "$child_runtime" == "$CALLER_RUNTIME_NORMALIZED" ]]; then + fail "memlab exposed the caller CBM_RUNTIME_DIR to the product" + fi + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE_NORMALIZED" ]]; then + fail "memlab exposed the caller CBM_CACHE_DIR to the product" + fi + if [[ "${child_runtime%/*}" != "${child_cache%/*}" || + "${child_runtime##*/}" != "runtime" || "${child_cache##*/}" != "cache" ]]; then + fail "memlab runtime/cache were not isolated beneath one private root" + fi + private_root="${child_runtime%/*}" +done < "$ENV_LOG" + +[[ ! -e "$private_root" ]] || fail "memlab left its private root behind: $private_root" + +echo "PASS: memlab harness isolates its daemon runtime and cache from the caller"