From b8c86977f43735f25d3905cc99f593aa63945e5a Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:22:32 +0530 Subject: [PATCH 1/3] security: constrain standalone application payloads --- docs/vendor-workflow.md | 22 +++++++- scripts/vendor | 114 +++++++++++++++++++++++++++++++++++----- tests/vendor.bats | 41 ++++++++++++++- 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/docs/vendor-workflow.md b/docs/vendor-workflow.md index d9eca52..dd94983 100644 --- a/docs/vendor-workflow.md +++ b/docs/vendor-workflow.md @@ -28,10 +28,28 @@ For an application that must run without a framework checkout, assemble a standalone directory: ```bash -scripts/vendor standalone . /tmp/base-bash-libs-v2 dist/app -PATH="$PWD/dist/app/bin:$PATH" dist/app/bin/app --help +scripts/vendor standalone . /tmp/base-bash-libs-v2 /tmp/my-app-dist +PATH="/tmp/my-app-dist/bin:$PATH" /tmp/my-app-dist/bin/app --help ``` +The application payload uses a fixed allowlist: `README.md`, `VERSION`, +`BASE_BASH_LIBS_PIN`, `bin/app`, `lib/app.sh`, and +`config/app.conf.example`. Development-only repository metadata, local +configuration overrides, tests, build output, caches, and previous +output/staging trees are not recursively copied. Put additional runtime assets +under `assets/` or `config/` and name each one explicitly: + +```bash +scripts/vendor standalone . /tmp/base-bash-libs-v2 /tmp/my-app-dist \ + --include assets/templates/default.conf \ + --include config/production.conf +``` + +Included paths must be regular files with no symlink in any path component. +The destination parent must already exist, and the destination must be outside +the application source tree; use a sibling or temporary output directory. This +prevents an output/staging tree from becoming part of its own package input. + The standalone payload contains two deterministic copies of the same verified framework bundle. The root copy is the authoritative runtime layout and is bound by `BASE_BASH_STANDALONE.release`; the launcher resolves its colocated diff --git a/scripts/vendor b/scripts/vendor index 082bcb2..f7bc3fc 100755 --- a/scripts/vendor +++ b/scripts/vendor @@ -12,7 +12,7 @@ Usage: scripts/vendor update BUNDLE DESTINATION scripts/vendor rollback DESTINATION scripts/vendor verify DESTINATION - scripts/vendor standalone APPLICATION BUNDLE DESTINATION + scripts/vendor standalone APPLICATION BUNDLE DESTINATION [--include assets/PATH|config/PATH ...] EOF } @@ -48,14 +48,63 @@ copy_verified_bundle() { cp -- "$source/MANIFEST.sha256" "$destination/MANIFEST.sha256" || return 1 } -copy_tree() { - local source="$1" destination="$2" file relative - while IFS= read -r -d '' file; do - relative="${file#"$source/"}" +validate_payload_file() { + local source="$1" relative="$2" candidate component + local -a components=() + + [[ -n "$relative" && "$relative" != /* && "$relative" != */ ]] || { + error "application payload path is not a clean relative path: $relative" + return 1 + } + [[ "$relative" != *//* ]] || { + error "application payload path contains an empty component: $relative" + return 1 + } + case "$relative" in + . | .. | ./* | ../* | */./* | */../* | */. | */.. | *$'\n'* | *$'\r'*) + error "application payload path contains an unsafe component: $relative" + return 1 + ;; + esac + + IFS='/' read -r -a components <<< "$relative" + candidate="$source" + for component in "${components[@]}"; do + candidate="$candidate/$component" + [[ ! -L "$candidate" ]] || { + error "application payload path traverses a symlink: $relative" + return 1 + } + done + [[ -f "$candidate" ]] || { + error "application payload entry is not a regular file: $relative" + return 1 + } +} + +copy_payload_files() { + local source="$1" destination="$2" relative + shift 2 + while IFS= read -r relative; do + [[ -n "$relative" ]] || continue mkdir -p "$destination/$(dirname -- "$relative")" || return 1 - cp -- "$file" "$destination/$relative" || return 1 - if [[ -x "$file" ]]; then chmod +x "$destination/$relative" || return 1; fi - done < <(find "$source" -type f -print0 | LC_ALL=C sort -z) + cp -- "$source/$relative" "$destination/$relative" || return 1 + if [[ -x "$source/$relative" ]]; then chmod +x "$destination/$relative" || return 1; fi + done < <(printf '%s\n' "$@" | LC_ALL=C sort -u) +} + +standalone_destination_is_external() { + local source="$1" destination="$2" destination_parent destination_name canonical + destination_parent="$(cd -- "$(dirname -- "$destination")" 2> /dev/null && pwd -P)" || { + error "standalone destination parent does not exist: $(dirname -- "$destination")" + return 1 + } + destination_name="$(basename -- "$destination")" + canonical="$destination_parent/$destination_name" + [[ "$canonical" != "$source" && "$canonical" != "$source/"* ]] || { + error "standalone destination must be outside the application source tree: $destination" + return 1 + } } write_lock() { @@ -195,11 +244,52 @@ verify_destination() { standalone_bundle() { local application="$1" framework_bundle="$2" destination="$3" temporary + local application_root relative + local -a payload_files=(README.md VERSION BASE_BASH_LIBS_PIN bin/app lib/app.sh config/app.conf.example) + local -A payload_seen=() + shift 3 + while (($#)); do + case "$1" in + --include) + (($# >= 2)) || { + error "--include requires a relative path under assets/ or config/" + return 2 + } + payload_files+=("$2") + shift 2 + ;; + *) + error "unknown standalone option: $1" + return 2 + ;; + esac + done + verify_bundle "$framework_bundle" || return $? - [[ -d "$application" && -f "$application/bin/app" ]] || { + [[ -d "$application" && ! -L "$application" && -f "$application/bin/app" ]] || { error "application is not a generated project: $application" return 1 } + application_root="$(cd -- "$application" && pwd -P)" || return 1 + standalone_destination_is_external "$application_root" "$destination" || return 2 + + local selected_paths=() + for relative in "${payload_files[@]}"; do + if [[ -z "${payload_seen[$relative]+set}" ]]; then + validate_payload_file "$application_root" "$relative" || return 2 + case "$relative" in + README.md | VERSION | BASE_BASH_LIBS_PIN | bin/app | lib/app.sh | config/app.conf.example) ;; + assets/* | config/*) ;; + *) + error "optional application payload must be explicitly selected under assets/ or config/: $relative" + return 2 + ;; + esac + payload_seen["$relative"]=1 + selected_paths+=("$relative") + fi + done + [[ ! -e "$destination" && ! -L "$destination" ]] || { error "refusing to overwrite standalone destination '$destination'" return 2 @@ -208,7 +298,7 @@ standalone_bundle() { error "unable to create a private staging directory beside '$destination'" return 1 } - if ! copy_tree "$application" "$temporary"; then + if ! copy_payload_files "$application_root" "$temporary" "${selected_paths[@]}"; then rm -rf -- "$temporary" return 1 fi @@ -270,11 +360,11 @@ main() { verify_destination "$2" ;; standalone) - (($# == 4)) || { + (($# >= 4)) || { usage return 2 } - standalone_bundle "$2" "$3" "$4" + standalone_bundle "$2" "$3" "$4" "${@:5}" ;; *) usage diff --git a/tests/vendor.bats b/tests/vendor.bats index e83478b..99dda40 100644 --- a/tests/vendor.bats +++ b/tests/vendor.bats @@ -82,11 +82,26 @@ SCRIPT } @test "standalone bundle contains its own launcher and vendored framework" { - bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" "$standalone" + mkdir -p "$application/assets" "$application/.git" "$application/dist/prior" + printf 'local-secret-marker\n' > "$application/.env" + printf 'repository-marker\n' > "$application/.git/config" + printf 'old-output-marker\n' > "$application/dist/prior/marker" + printf 'runtime asset\n' > "$application/assets/runtime.txt" + + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" "$standalone" \ + --include assets/runtime.txt [ "$status" -eq 0 ] [ -x "$standalone/bin/base-bash" ] [ -x "$standalone/bin/app" ] [ -d "$standalone/lib/bash" ] + [ -f "$standalone/config/app.conf.example" ] + [ "$(<"$standalone/assets/runtime.txt")" = 'runtime asset' ] + [ ! -e "$standalone/.env" ] + [ ! -e "$standalone/.git" ] + [ ! -e "$standalone/dist" ] + [ ! -e "$standalone/Makefile" ] + [ ! -e "$standalone/tests/app.bats" ] + [ ! -e "$standalone/.github" ] [ "$(<"$standalone/VERSION")" = "0.1.0" ] [ -f "$standalone/vendor/base-bash-libs/base-bash-libs.lock" ] bats_run "$BASE_REPO_ROOT/scripts/vendor" verify "$standalone/vendor/base-bash-libs" @@ -106,6 +121,30 @@ SCRIPT [[ "$output" == *"base-bash $expected_version"* ]] } +@test "standalone rejects destinations inside source and unsafe optional payload entries" { + mkdir -p "$application/dist" "$application/assets" + printf 'outside\n' > "$TEST_TMPDIR/outside-marker" + ln -s "$TEST_TMPDIR/outside-marker" "$application/assets/linked-marker" + + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" \ + "$application/dist/standalone" + [ "$status" -eq 2 ] + [[ "$output" == *"destination must be outside the application source tree"* ]] + [ ! -e "$application/dist/standalone" ] + + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" \ + "$TEST_TMPDIR/standalone-symlink" --include assets/linked-marker + [ "$status" -eq 2 ] + [[ "$output" == *"traverses a symlink"* ]] + [ ! -e "$TEST_TMPDIR/standalone-symlink" ] + + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" \ + "$TEST_TMPDIR/standalone-unlisted" --include tests/app.bats + [ "$status" -eq 2 ] + [[ "$output" == *"must be explicitly selected under assets/ or config/"* ]] + [ ! -e "$TEST_TMPDIR/standalone-unlisted" ] +} + @test "vendor verification detects tampering" { "$BASE_REPO_ROOT/scripts/vendor" create "$framework_bundle" "$vendor_tree" printf 'tampered\n' >> "$vendor_tree/VERSION" From 26f0aef33e1806fa5618c1815b92bd9a244519f2 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:39:08 +0530 Subject: [PATCH 2/3] fix(security): harden standalone payload staging --- base_api_manifest.yaml | 1 + bin/base-bash | 12 +++- docs/vendor-workflow.md | 18 ++++-- scripts/standalone-app-payloads.txt | 7 +++ scripts/vendor | 87 +++++++++++++++++++------- tests/launcher.bats | 3 +- tests/vendor.bats | 94 +++++++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 31 deletions(-) create mode 100644 scripts/standalone-app-payloads.txt diff --git a/base_api_manifest.yaml b/base_api_manifest.yaml index 8b9504e..a1000ec 100644 --- a/base_api_manifest.yaml +++ b/base_api_manifest.yaml @@ -41,6 +41,7 @@ artifacts: - docs/single-file-distribution.md|documentation|single-file and deterministic bundle workflow - docs/vendor-workflow.md|documentation|offline vendor and standalone workflow - docs/v2-symbol-map.md|documentation|v2 migration and symbol map + - scripts/standalone-app-payloads.txt|runtime-manifest|shared generated-app and standalone payload allowlist - lib/bash/README.md|documentation|package layout and namespace contract - lib/bash/tests/test_helper.sh|test-support|shared BATS helpers diff --git a/bin/base-bash b/bin/base-bash index 2502c10..4deeaab 100755 --- a/bin/base-bash +++ b/bin/base-bash @@ -619,11 +619,19 @@ base_launcher_init() { return 1 } - files=(README.md VERSION BASE_BASH_LIBS_PIN .gitignore .editorconfig .shellcheckrc bin/app lib/app.sh config/app.conf.example tests/app.bats) + package_root="$(base_launcher_package_root)" || return 1 + [[ -r "$package_root/scripts/standalone-app-payloads.txt" ]] || { + printf 'ERROR: standalone application payload manifest is missing from %s\n' "$package_root" >&2 + return 1 + } + while IFS= read -r argument || [[ -n "$argument" ]]; do + [[ -n "$argument" && "$argument" != \#* ]] || continue + files+=("$argument") + done < "$package_root/scripts/standalone-app-payloads.txt" + files+=(.gitignore .editorconfig .shellcheckrc tests/app.bats) if [[ "$profile" == standard ]]; then files+=(tests/run.sh Makefile .github/workflows/validate.yml tools/shfmt-check.sh) fi - package_root="$(base_launcher_package_root)" || return 1 __base_bash_libs_launcher_init_resolve_pin__ "$package_root" || return $? for argument in "${files[@]}"; do mkdir -p "$target_dir/$(dirname -- "$argument")" || return 1 diff --git a/docs/vendor-workflow.md b/docs/vendor-workflow.md index dd94983..14bc606 100644 --- a/docs/vendor-workflow.md +++ b/docs/vendor-workflow.md @@ -32,9 +32,9 @@ scripts/vendor standalone . /tmp/base-bash-libs-v2 /tmp/my-app-dist PATH="/tmp/my-app-dist/bin:$PATH" /tmp/my-app-dist/bin/app --help ``` -The application payload uses a fixed allowlist: `README.md`, `VERSION`, -`BASE_BASH_LIBS_PIN`, `bin/app`, `lib/app.sh`, and -`config/app.conf.example`. Development-only repository metadata, local +The required application payload is defined once in +`scripts/standalone-app-payloads.txt`; both the project generator and standalone +packager use that list. Development-only repository metadata, local configuration overrides, tests, build output, caches, and previous output/staging trees are not recursively copied. Put additional runtime assets under `assets/` or `config/` and name each one explicitly: @@ -46,9 +46,15 @@ scripts/vendor standalone . /tmp/base-bash-libs-v2 /tmp/my-app-dist \ ``` Included paths must be regular files with no symlink in any path component. -The destination parent must already exist, and the destination must be outside -the application source tree; use a sibling or temporary output directory. This -prevents an output/staging tree from becoming part of its own package input. +The packager validates each path before copying, checks the source again around +the copy, and verifies the staged bytes against the pre-copy digest; if a path +changes during staging, packaging fails and the incomplete staging tree is +discarded. These checks apply equally to required files and explicitly selected +runtime assets. The destination parent must already exist, and the destination +must be outside the application source tree; containment is checked using +filesystem identity so alternate path casing on case-insensitive filesystems +cannot bypass it. Use a sibling or temporary output directory. This prevents +an output/staging tree from becoming part of its own package input. The standalone payload contains two deterministic copies of the same verified framework bundle. The root copy is the authoritative runtime layout and is diff --git a/scripts/standalone-app-payloads.txt b/scripts/standalone-app-payloads.txt new file mode 100644 index 0000000..4a03aa2 --- /dev/null +++ b/scripts/standalone-app-payloads.txt @@ -0,0 +1,7 @@ +# Required generated-app payload files shared by the launcher and vendor tool. +README.md +VERSION +BASE_BASH_LIBS_PIN +bin/app +lib/app.sh +config/app.conf.example diff --git a/scripts/vendor b/scripts/vendor index f7bc3fc..cea4b94 100755 --- a/scripts/vendor +++ b/scripts/vendor @@ -83,28 +83,52 @@ validate_payload_file() { } copy_payload_files() { - local source="$1" destination="$2" relative + local source="$1" destination="$2" relative source_hash copied_hash source_executable shift 2 while IFS= read -r relative; do [[ -n "$relative" ]] || continue + validate_payload_file "$source" "$relative" || return 1 + source_hash="$(hash_file "$source/$relative")" || return 1 + validate_payload_file "$source" "$relative" || return 1 + source_executable=0 + [[ -x "$source/$relative" ]] && source_executable=1 mkdir -p "$destination/$(dirname -- "$relative")" || return 1 - cp -- "$source/$relative" "$destination/$relative" || return 1 - if [[ -x "$source/$relative" ]]; then chmod +x "$destination/$relative" || return 1; fi + cp -P -- "$source/$relative" "$destination/$relative" || return 1 + [[ -f "$destination/$relative" && ! -L "$destination/$relative" ]] || { + error "application payload changed to a symlink while it was copied: $relative" + return 1 + } + validate_payload_file "$source" "$relative" || return 1 + copied_hash="$(hash_file "$destination/$relative")" || return 1 + [[ "$copied_hash" == "$source_hash" ]] || { + error "application payload changed while it was copied: $relative" + return 1 + } + if ((source_executable)); then chmod +x "$destination/$relative" || return 1; fi done < <(printf '%s\n' "$@" | LC_ALL=C sort -u) } standalone_destination_is_external() { - local source="$1" destination="$2" destination_parent destination_name canonical + local source="$1" destination="$2" destination_parent destination_name canonical ancestor destination_parent="$(cd -- "$(dirname -- "$destination")" 2> /dev/null && pwd -P)" || { error "standalone destination parent does not exist: $(dirname -- "$destination")" return 1 } destination_name="$(basename -- "$destination")" canonical="$destination_parent/$destination_name" - [[ "$canonical" != "$source" && "$canonical" != "$source/"* ]] || { + ancestor="$destination_parent" + while :; do + if [[ "$ancestor" -ef "$source" ]]; then + error "standalone destination must be outside the application source tree: $destination" + return 1 + fi + [[ "$ancestor" == / ]] && break + ancestor="$(dirname -- "$ancestor")" || return 1 + done + if [[ (-e "$canonical" || -L "$canonical") && "$canonical" -ef "$source" ]]; then error "standalone destination must be outside the application source tree: $destination" return 1 - } + fi } write_lock() { @@ -243,10 +267,10 @@ verify_destination() { } standalone_bundle() { - local application="$1" framework_bundle="$2" destination="$3" temporary - local application_root relative - local -a payload_files=(README.md VERSION BASE_BASH_LIBS_PIN bin/app lib/app.sh config/app.conf.example) - local -A payload_seen=() + local application="$1" framework_bundle="$2" destination="$3" temporary application_payload relative + local application_root required_payload + local -a payload_files=() selected_paths=() + local -A payload_seen=() required_payloads=() shift 3 while (($#)); do case "$1" in @@ -266,6 +290,17 @@ standalone_bundle() { done verify_bundle "$framework_bundle" || return $? + [[ -r "$repo_root/scripts/standalone-app-payloads.txt" ]] || { + error 'standalone application payload manifest is missing.' + return 1 + } + while IFS= read -r required_payload || [[ -n "$required_payload" ]]; do + [[ -n "$required_payload" && "$required_payload" != \#* ]] || continue + if [[ -z "${required_payloads[$required_payload]+set}" ]]; then + required_payloads["$required_payload"]=1 + payload_files+=("$required_payload") + fi + done < "$repo_root/scripts/standalone-app-payloads.txt" [[ -d "$application" && ! -L "$application" && -f "$application/bin/app" ]] || { error "application is not a generated project: $application" return 1 @@ -273,18 +308,13 @@ standalone_bundle() { application_root="$(cd -- "$application" && pwd -P)" || return 1 standalone_destination_is_external "$application_root" "$destination" || return 2 - local selected_paths=() for relative in "${payload_files[@]}"; do if [[ -z "${payload_seen[$relative]+set}" ]]; then validate_payload_file "$application_root" "$relative" || return 2 - case "$relative" in - README.md | VERSION | BASE_BASH_LIBS_PIN | bin/app | lib/app.sh | config/app.conf.example) ;; - assets/* | config/*) ;; - *) + if [[ -z "${required_payloads[$relative]+set}" && "$relative" != assets/* && "$relative" != config/* ]]; then error "optional application payload must be explicitly selected under assets/ or config/: $relative" return 2 - ;; - esac + fi payload_seen["$relative"]=1 selected_paths+=("$relative") fi @@ -298,7 +328,8 @@ standalone_bundle() { error "unable to create a private staging directory beside '$destination'" return 1 } - if ! copy_payload_files "$application_root" "$temporary" "${selected_paths[@]}"; then + application_payload="$temporary/.application-payload" + if ! copy_payload_files "$application_root" "$application_payload" "${selected_paths[@]}"; then rm -rf -- "$temporary" return 1 fi @@ -310,13 +341,25 @@ standalone_bundle() { ! copy_verified_bundle "$framework_bundle" "$temporary" || ! copy_verified_bundle "$framework_bundle" "$temporary/vendor/base-bash-libs" || ! write_lock "$temporary/vendor/base-bash-libs" "$framework_bundle" standalone || - ! cp -- "$framework_bundle/bin/base-bash" "$temporary/bin/base-bash" || - ! cp -- "$application/VERSION" "$temporary/VERSION" || - ! cp -- "$application/README.md" "$temporary/README.md" || - ! chmod +x "$temporary/bin/base-bash" "$temporary/bin/app"; then + ! chmod +x "$temporary/bin/base-bash"; then rm -rf -- "$temporary" return 1 fi + while IFS= read -r relative; do + [[ -n "$relative" ]] || continue + mkdir -p "$temporary/$(dirname -- "$relative")" || { + rm -rf -- "$temporary" + return 1 + } + mv -- "$application_payload/$relative" "$temporary/$relative" || { + rm -rf -- "$temporary" + return 1 + } + done < <(printf '%s\n' "${selected_paths[@]}" | LC_ALL=C sort -u) + rm -rf -- "$application_payload" || { + rm -rf -- "$temporary" + return 1 + } printf 'standalone_format=1\nframework_lock=%s\nprovenance=verified-offline-bundle\n' \ "$(hash_file "$framework_bundle/MANIFEST.sha256")" > "$temporary/BASE_BASH_STANDALONE.release" || { rm -rf -- "$temporary" diff --git a/tests/launcher.bats b/tests/launcher.bats index dec4298..6d0e3b1 100644 --- a/tests/launcher.bats +++ b/tests/launcher.bats @@ -27,10 +27,11 @@ launcher_file_mode() { copy_launcher_package() { local package_root="$1" - mkdir -p "$package_root/bin" "$package_root/lib/bash" + mkdir -p "$package_root/bin" "$package_root/lib/bash" "$package_root/scripts" cp "$BASE_REPO_ROOT/bin/base-bash" "$package_root/bin/base-bash" cp "$BASE_REPO_ROOT/VERSION" "$package_root/VERSION" cp "$BASE_REPO_ROOT/lib/bash/base-bash-libs.release" "$package_root/lib/bash/base-bash-libs.release" + cp "$BASE_REPO_ROOT/scripts/standalone-app-payloads.txt" "$package_root/scripts/standalone-app-payloads.txt" chmod +x "$package_root/bin/base-bash" } diff --git a/tests/vendor.bats b/tests/vendor.bats index 99dda40..9050e98 100644 --- a/tests/vendor.bats +++ b/tests/vendor.bats @@ -13,6 +13,42 @@ setup() { BASE_BASH_LIBS_DIR="$BASE_BASH_DIR" "$BASE_REPO_ROOT/bin/base-bash" init --profile standard --dir "$application" >/dev/null } +vendor_test_make_copy_race_stub() { + local stub_dir="$TEST_TMPDIR/racing-cp-bin" + mkdir -p "$stub_dir" + cat > "$stub_dir/cp" <<'EOF' +#!/usr/bin/env bash +set -u +source_path="${@: -2:1}" +if [[ "$source_path" == "${VENDOR_TEST_RACE_SOURCE-}" ]]; then + if [[ "${VENDOR_TEST_RACE_MODE-}" == parent ]]; then + mv -- "$VENDOR_TEST_RACE_PARENT" "$VENDOR_TEST_RACE_BACKUP" || exit 1 + ln -s -- "$VENDOR_TEST_RACE_TARGET" "$VENDOR_TEST_RACE_PARENT" || { + mv -- "$VENDOR_TEST_RACE_BACKUP" "$VENDOR_TEST_RACE_PARENT" + exit 1 + } + "$VENDOR_TEST_REAL_CP" "$@" + copy_status=$? + rm -f -- "$VENDOR_TEST_RACE_PARENT" + mv -- "$VENDOR_TEST_RACE_BACKUP" "$VENDOR_TEST_RACE_PARENT" || exit 1 + exit "$copy_status" + fi + mv -- "$VENDOR_TEST_RACE_SOURCE" "$VENDOR_TEST_RACE_BACKUP" || exit 1 + ln -s -- "$VENDOR_TEST_RACE_TARGET" "$VENDOR_TEST_RACE_SOURCE" || { + mv -- "$VENDOR_TEST_RACE_BACKUP" "$VENDOR_TEST_RACE_SOURCE" + exit 1 + } + "$VENDOR_TEST_REAL_CP" "$@" + copy_status=$? + rm -f -- "$VENDOR_TEST_RACE_SOURCE" + mv -- "$VENDOR_TEST_RACE_BACKUP" "$VENDOR_TEST_RACE_SOURCE" || exit 1 + exit "$copy_status" +fi +exec "$VENDOR_TEST_REAL_CP" "$@" +EOF + chmod +x "$stub_dir/cp" +} + @test "vendor create and verify are offline and immutable" { bats_run "$BASE_REPO_ROOT/scripts/vendor" create "$framework_bundle" "$vendor_tree" [ "$status" -eq 0 ] @@ -145,6 +181,64 @@ SCRIPT [ ! -e "$TEST_TMPDIR/standalone-unlisted" ] } +@test "standalone destination containment uses filesystem identity on case-insensitive volumes" { + local alternate_application="${application^^}" + mkdir -p "$application/dist" + [[ "$application" -ef "$alternate_application" ]] || skip "The test volume is case-sensitive." + + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" \ + "$alternate_application/dist/case-aliased-output" + [ "$status" -eq 2 ] + [[ "$output" == *"destination must be outside the application source tree"* ]] + [ ! -e "$application/dist/case-aliased-output" ] +} + +@test "standalone refuses leaf and parent symlink swaps during payload copy" { + local real_cp canonical_application outside_marker destination="$TEST_TMPDIR/race-leaf" + real_cp="$(command -v cp)" + canonical_application="$(cd -- "$application" && pwd -P)" + printf 'external sensitive marker\n' > "$TEST_TMPDIR/outside-marker" + vendor_test_make_copy_race_stub + + bats_run env PATH="$TEST_TMPDIR/racing-cp-bin:$BASE_TEST_ORIG_PATH" \ + VENDOR_TEST_REAL_CP="$real_cp" \ + VENDOR_TEST_RACE_MODE=leaf \ + VENDOR_TEST_RACE_SOURCE="$canonical_application/VERSION" \ + VENDOR_TEST_RACE_BACKUP="$canonical_application/VERSION.original" \ + VENDOR_TEST_RACE_TARGET="$TEST_TMPDIR/outside-marker" \ + "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" "$destination" + [ "$status" -eq 1 ] + [[ "$output" == *"changed to a symlink while it was copied"* ]] || { + printf 'Unexpected standalone staging output: %s\n' "$output" >&2 + false + } + [ ! -e "$destination" ] + [ -f "$application/VERSION" ] + [ ! -e "$application/VERSION.original" ] + + mkdir -p "$application/assets" "$TEST_TMPDIR/outside-assets" + printf 'trusted runtime asset\n' > "$application/assets/runtime.txt" + printf 'external sensitive marker\n' > "$TEST_TMPDIR/outside-assets/runtime.txt" + destination="$TEST_TMPDIR/race-parent" + bats_run env PATH="$TEST_TMPDIR/racing-cp-bin:$BASE_TEST_ORIG_PATH" \ + VENDOR_TEST_REAL_CP="$real_cp" \ + VENDOR_TEST_RACE_MODE=parent \ + VENDOR_TEST_RACE_SOURCE="$canonical_application/assets/runtime.txt" \ + VENDOR_TEST_RACE_PARENT="$canonical_application/assets" \ + VENDOR_TEST_RACE_BACKUP="$canonical_application/assets.original" \ + VENDOR_TEST_RACE_TARGET="$TEST_TMPDIR/outside-assets" \ + "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" "$destination" \ + --include assets/runtime.txt + [ "$status" -eq 1 ] + [[ "$output" == *"changed while it was copied"* ]] || { + printf 'Unexpected standalone staging output: %s\n' "$output" >&2 + false + } + [ ! -e "$destination" ] + [ -f "$application/assets/runtime.txt" ] + [ ! -e "$application/assets.original" ] +} + @test "vendor verification detects tampering" { "$BASE_REPO_ROOT/scripts/vendor" create "$framework_bundle" "$vendor_tree" printf 'tampered\n' >> "$vendor_tree/VERSION" From c0939730055e21e7459f6ca36dcbf2c37de69e64 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:47:38 +0530 Subject: [PATCH 3/3] test(vendor): remove unused local from race fixture --- tests/vendor.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/vendor.bats b/tests/vendor.bats index 9050e98..341da8e 100644 --- a/tests/vendor.bats +++ b/tests/vendor.bats @@ -194,7 +194,7 @@ SCRIPT } @test "standalone refuses leaf and parent symlink swaps during payload copy" { - local real_cp canonical_application outside_marker destination="$TEST_TMPDIR/race-leaf" + local real_cp canonical_application destination="$TEST_TMPDIR/race-leaf" real_cp="$(command -v cp)" canonical_application="$(cd -- "$application" && pwd -P)" printf 'external sensitive marker\n' > "$TEST_TMPDIR/outside-marker"