diff --git a/base_api_manifest.yaml b/base_api_manifest.yaml index a07a3fa..63bcf42 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 d9eca52..14bc606 100644 --- a/docs/vendor-workflow.md +++ b/docs/vendor-workflow.md @@ -28,10 +28,34 @@ 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 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: + +```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 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 bound by `BASE_BASH_STANDALONE.release`; the launcher resolves its colocated 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 082bcb2..cea4b94 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,87 @@ 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 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 -- "$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 -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 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" + 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() { @@ -194,12 +267,59 @@ verify_destination() { } standalone_bundle() { - local application="$1" framework_bundle="$2" destination="$3" temporary + 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 + --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" ]] || { + [[ -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 } + application_root="$(cd -- "$application" && pwd -P)" || return 1 + standalone_destination_is_external "$application_root" "$destination" || return 2 + + for relative in "${payload_files[@]}"; do + if [[ -z "${payload_seen[$relative]+set}" ]]; then + validate_payload_file "$application_root" "$relative" || return 2 + 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 + fi + payload_seen["$relative"]=1 + selected_paths+=("$relative") + fi + done + [[ ! -e "$destination" && ! -L "$destination" ]] || { error "refusing to overwrite standalone destination '$destination'" return 2 @@ -208,7 +328,8 @@ standalone_bundle() { error "unable to create a private staging directory beside '$destination'" return 1 } - if ! copy_tree "$application" "$temporary"; then + application_payload="$temporary/.application-payload" + if ! copy_payload_files "$application_root" "$application_payload" "${selected_paths[@]}"; then rm -rf -- "$temporary" return 1 fi @@ -220,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" @@ -270,11 +403,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/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 585238d..3433614 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 ] @@ -84,11 +120,26 @@ SCRIPT @test "standalone bundle contains its own launcher and vendored framework" { local stdout_file="$TEST_TMPDIR/standalone.stdout" stderr_file="$TEST_TMPDIR/standalone.stderr" - 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" @@ -117,6 +168,88 @@ 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 "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 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"