#![cfg(target_os = "linux")]
use std::process::Command;
fn run_case(body: &str) {
for shell in ["sh", "bash"] {
let directory = tempfile::tempdir().expect("create fixture directory");
std::fs::write(
directory.path().join("protocol.sh"),
rch_common::REMOTE_PROCESS_IDENTITY_SCRIPT,
)
.expect("write embedded production protocol");
let script = format!("{}\n{body}", rch_common::REMOTE_PROCESS_IDENTITY_SCRIPT);
let output = Command::new(shell)
.args(["-c", &script, "rch-process-test"])
.arg(directory.path())
.output()
.expect("run shell regression");
assert!(
output.status.success(),
"{shell}: {:?}\nstdout: {}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
}
const LEADER_EXIT: &str = r#"
rch_test_dir=$1
rch_test_leader=
cleanup() {
# Teardown also checks birth identity, never an unbound historical PGID.
if rch_read_record "$rch_test_dir/receipt" regression; then
if rch_remote_leader_matches; then
kill -KILL -"$rch_pgid" 2>/dev/null || :
elif [ -r "$rch_test_dir/witness" ]; then
read -r test_pid test_start < "$rch_test_dir/witness"
if rch_read_process "$test_pid" && [ "$rch_observed_start" = "$test_start" ]; then
kill -KILL -"$rch_pgid" 2>/dev/null || :
fi
fi
fi
if [ -n "$rch_test_leader" ]; then wait "$rch_test_leader" 2>/dev/null || :; fi
}
trap cleanup 0
setsid sh -c '
. "$1/protocol.sh"
rch_remote_record "$1/receipt" regression || exit 70
trap "exit 0" TERM
sh -c '\''trap "" TERM; printf ready > "$1/ready"; exec sleep 30'\'' survivor "$1" &
printf "%s\n" "$!" > "$1/member"
wait
' leader "$rch_test_dir" >/dev/null 2>&1 &
rch_test_leader=$!
attempt=0
while [ ! -s "$rch_test_dir/member" ] || [ ! -f "$rch_test_dir/ready" ]; do
attempt=$((attempt + 1))
[ "$attempt" -lt 500 ] || { echo 'fixture did not start' >&2; exit 1; }
sleep 0.01
done
read -r test_member < "$rch_test_dir/member"
rch_read_record "$rch_test_dir/receipt" regression || exit 1
rch_read_process "$test_member" || exit 1
printf '%s %s\n' "$test_member" "$rch_observed_start" > "$rch_test_dir/witness"
rch_remote_cancel "$rch_test_dir/receipt" regression term || {
echo 'TERM-to-KILL escalation lost group ownership' >&2; exit 1;
}
if rch_read_process "$test_member"; then
echo 'TERM-ignoring child survived cancellation' >&2
exit 1
fi
wait "$rch_test_leader" || :
"#;
#[test]
fn term_escalation_survives_leader_exit() {
run_case(LEADER_EXIT);
}
const WITNESS_FENCING: &str = r#"
rch_identified=1
rch_boot=boot
rch_pgid=300
rch_start=100
rch_witness_identity=boot:300:100
rch_witnesses='999:999 301:200'
rch_term_sent=1
test_boot=boot
test_start=200
test_member_live=1
test_signals=0
rch_remote_leader_matches() { return 1; }
rch_read_process() {
[ "$1" = 301 ] && [ "$test_member_live" = 1 ] || return 1
rch_observed_boot=$test_boot
rch_observed_start=$test_start
}
kill() { test_signals=$((test_signals + 1)); }
refused() {
rch_signal_group "$1"
code=$?
[ "$code" = 43 ] && [ "$test_signals" = 1 ] || exit 1
}
# An unavailable first witness must not hide a later live, matching witness.
rch_signal_group KILL || exit 1
[ "$test_signals" = 1 ] || exit 1
# Witnesses authorize only escalation, never the initial TERM.
refused TERM
rch_term_sent=0
refused KILL
rch_term_sent=1
# PID reuse, reboot, exit/group departure, and a different receipt all fence it.
test_start=201
refused KILL
test_start=200
test_boot=other-boot
refused KILL
test_boot=boot
test_member_live=0
refused KILL
test_member_live=1
rch_witness_identity=boot:400:100
refused KILL
rch_witness_identity=boot:300:101
refused KILL
rch_witness_identity=boot:300:100
rch_identified=0
refused KILL
"#;
#[test]
fn witnesses_require_matching_birth_group_boot_and_current_term() {
run_case(WITNESS_FENCING);
}
const CAPTURE_RACE: &str = r#"
rch_identified=1
rch_boot=boot
rch_pgid=300
rch_start=100
rch_snapshot='301 300 S'
rch_witnesses=stale
rch_witness_identity=stale
test_leader_reads=0
rch_remote_leader_matches() {
test_leader_reads=$((test_leader_reads + 1))
[ "$test_leader_reads" = 1 ]
}
rch_read_process() { rch_observed_boot=boot; rch_observed_start=200; }
rch_capture_group_witnesses
code=$?
[ "$code" = 43 ] && [ -z "$rch_witnesses" ] && [ -z "$rch_witness_identity" ] || exit 1
"#;
#[test]
fn leader_exit_during_capture_does_not_transfer_authority() {
run_case(CAPTURE_RACE);
}
const NO_INHERITED_AUTHORITY: &str = r#"
# Even a parse failure must clear authority inherited from a previous call.
rch_witnesses='301:200'
rch_witness_identity=boot:300:100
rch_term_sent=1
rch_read_record() { return 42; }
rch_remote_cancel /unused regression term
code=$?
[ "$code" = 42 ] && [ -z "$rch_witnesses" ] &&
[ -z "$rch_witness_identity" ] && [ "$rch_term_sent" = 0 ] || exit 1
# A live group with no matching leader at entry must remain quarantined.
rch_read_record() { rch_identified=1; rch_boot=boot; rch_pgid=300; rch_start=100; }
rch_group_state() { rch_snapshot='301 300 S'; return 1; }
rch_remote_leader_matches() { return 1; }
rch_read_process() { rch_observed_boot=boot; rch_observed_start=200; }
kill() { echo 'unexpected signal to unowned group' >&2; exit 99; }
for mode in term kill; do
rch_witnesses='301:200'
rch_witness_identity=boot:300:100
rch_term_sent=1
rch_remote_cancel /unused regression "$mode"
code=$?
[ "$code" = 43 ] || exit 1
done
"#;
#[test]
fn separate_calls_and_preexisting_orphans_cannot_inherit_authority() {
run_case(NO_INHERITED_AUTHORITY);
}
const NAMESPACE_FENCING: &str = r#"
rch_test_dir=$1
# Simulate foreign, coincidentally equal, and missing namespace IDs through the
# production builtin-read boundary; this test needs no namespace privileges.
read() { rch_status_line=$test_status; }
cat() { printf unexpected > "$rch_test_dir/cat-called"; return 1; }
ps() { printf unexpected > "$rch_test_dir/ps-called"; return 1; }
kill() { echo 'unexpected signal with foreign procfs' >&2; exit 99; }
for test_status in "NSpid: $$ 1" "NSpid: $$ $$" "NSpid: 0" 'NSpid:'; do
rch_pgid=300
rch_read_process 301
[ "$?" = 1 ] || exit 1
rch_group_state
[ "$?" = 2 ] || exit 1
rch_remote_record "$rch_test_dir/receipt" regression
[ "$?" = 1 ] && [ ! -e "$rch_test_dir/receipt" ] || exit 1
done
rch_read_record() { rch_identified=1; rch_boot=boot; rch_pgid=300; rch_start=100; }
rch_remote_cancel /unused regression term
[ "$?" = 44 ] || exit 1
# An unreadable status or a kernel with no NSpid must not prove group absence.
read() { return 1; }
rch_group_state
[ "$?" = 2 ] || exit 1
[ ! -e "$rch_test_dir/cat-called" ] && [ ! -e "$rch_test_dir/ps-called" ] || exit 1
"#;
#[test]
fn foreign_procfs_cannot_authorize_signals_or_empty_group_claims() {
run_case(NAMESPACE_FENCING);
}
const SUBSHELL_OBSERVATION: &str = r#"
# $$ names the invoking shell inside ( ... ), but /proc/self is the subshell.
# The watchdog's timer reads its own sleep from a subshell; failing closed
# there silently disabled every remote build deadline.
rch_check_procfs_namespace || exit 1
( rch_check_procfs_namespace ) || exit 2
( ( rch_check_procfs_namespace ) ) || exit 3
(
sleep 30 &
test_sleep=$!
rch_pgid=$(ps -o pgid= -p "$test_sleep" | tr -d ' ')
rch_read_process "$test_sleep"
test_code=$?
kill "$test_sleep"
wait "$test_sleep" 2>/dev/null
[ "$test_code" = 0 ] && [ -n "$rch_observed_start" ]
) || exit 4
"#;
#[test]
fn subshells_observe_their_own_procfs_namespace() {
run_case(SUBSHELL_OBSERVATION);
}
const PROCESS_STATES: &str = r#"
rch_pgid=300
rch_check_procfs_namespace() { return 0; }
cat() {
case "$1" in
/proc/sys/kernel/random/boot_id)
printf '01234567-89ab-cdef-0123-456789abcdef\n';;
*)
printf '301 (worker (test)) %s 1 300' "$test_state"
field=0
while [ "$field" -lt 16 ]; do printf ' 0'; field=$((field + 1)); done
printf ' 200\n';;
esac
}
ps() { printf '%s %s %s S 1\n301 301 300 %s 1\n' "$$" "$$" "$$" "$test_state"; }
for test_state in R S D T t K W P I; do
rch_read_process 301 || exit 1
[ "$rch_observed_start" = 200 ] || exit 1
rch_group_state
[ "$?" = 1 ] || exit 1
done
for test_state in Z X x; do
if rch_read_process 301; then echo 'dead member accepted' >&2; exit 1; fi
rch_group_state
[ "$?" = 0 ] || exit 1
done
for test_state in Q '?' SS; do
if rch_read_process 301; then echo 'unknown member state accepted' >&2; exit 1; fi
done
test_state=Q
rch_group_state
[ "$?" = 2 ] || exit 1
"#;
#[test]
fn process_state_observation_is_conservative_and_handles_parentheses() {
run_case(PROCESS_STATES);
}