diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 20fa513..fcf73d3 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -121,6 +121,8 @@ Security-sensitive cleanup paths are covered by BATS regression tests, including - `tests/clean_user_core.bats` - `tests/clean_dev_caches.bats` - `tests/clean_system_maintenance.bats` +- `tests/purge.bats` +- `tests/core_safe_functions.bats` **System Memory Reports** computation uses bulk `find -exec stat` to avoid bash loop child-process limits on corrupted systems. `bin/clean.sh` dry-run export temp files rely on tracked temp lifecycle (`create_temp_file()` + trap cleanup) to avoid orphan temp artifacts. @@ -132,6 +134,7 @@ Latest local verification for this release branch: - `bats tests/clean_user_core.bats` passed (13/13) - `bats tests/clean_dev_caches.bats` passed (8/8) - `bats tests/clean_system_maintenance.bats` passed (40/40) +- `bats tests/purge.bats tests/core_safe_functions.bats` passed (67/67) Run tests: diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 268f053..2f460cf 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -569,16 +569,38 @@ select_purge_categories() { fi done local original_stty="" + local previous_exit_trap="" + local previous_int_trap="" + local previous_term_trap="" + local terminal_restored=false if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then original_stty=$(stty -g 2> /dev/null || echo "") fi + previous_exit_trap=$(trap -p EXIT || true) + previous_int_trap=$(trap -p INT || true) + previous_term_trap=$(trap -p TERM || true) # Terminal control functions restore_terminal() { + # Avoid trap churn when restore is called repeatedly via RETURN/EXIT paths. + if [[ "${terminal_restored:-false}" == "true" ]]; then + return + fi + terminal_restored=true + trap - EXIT INT TERM show_cursor if [[ -n "${original_stty:-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true fi + if [[ -n "$previous_exit_trap" ]]; then + eval "$previous_exit_trap" + fi + if [[ -n "$previous_int_trap" ]]; then + eval "$previous_int_trap" + fi + if [[ -n "$previous_term_trap" ]]; then + eval "$previous_term_trap" + fi } # shellcheck disable=SC2329 handle_interrupt() { diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 7415f6a..fc5cfee 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -249,6 +249,11 @@ safe_remove() { local rm_exit=0 error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove + # Preserve interrupt semantics so callers can abort long-running deletions. + if [[ $rm_exit -ge 128 ]]; then + return "$rm_exit" + fi + if [[ $rm_exit -eq 0 ]]; then # Log successful removal log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index a720787..5805f04 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -110,6 +110,19 @@ teardown() { [ "$status" -eq 0 ] } +@test "safe_remove preserves interrupt exit codes" { + local test_file="$TEST_DIR/interrupt_file" + echo "test" > "$test_file" + + run bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + rm() { return 130; } + safe_remove '$test_file' true + " + [ "$status" -eq 130 ] + [ -f "$test_file" ] +} + @test "safe_remove in silent mode suppresses error output" { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1" [ "$status" -eq 1 ] diff --git a/tests/purge.bats b/tests/purge.bats index 49337c7..9e0ea96 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -308,6 +308,44 @@ EOF [ "$status" -eq 0 ] } +@test "select_purge_categories restores caller EXIT/INT/TERM traps" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +trap 'echo parent-exit' EXIT +trap 'echo parent-int' INT +trap 'echo parent-term' TERM + +before_exit=$(trap -p EXIT) +before_int=$(trap -p INT) +before_term=$(trap -p TERM) + +PURGE_CATEGORY_SIZES="1" +PURGE_RECENT_CATEGORIES="false" +select_purge_categories "demo" <<< $'\n' > /dev/null 2>&1 || true + +after_exit=$(trap -p EXIT) +after_int=$(trap -p INT) +after_term=$(trap -p TERM) + +if [[ "$before_exit" == "$after_exit" && "$before_int" == "$after_int" && "$before_term" == "$after_term" ]]; then + echo "PASS" +else + echo "FAIL" + echo "before_exit=$before_exit" + echo "after_exit=$after_exit" + echo "before_int=$before_int" + echo "after_int=$after_int" + echo "before_term=$before_term" + echo "after_term=$after_term" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + @test "confirm_purge_cleanup accepts Enter" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail