use core::fmt;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use super::dogs::dog_env;
use crate::terminal_safe::sanitise;
pub(crate) const ON_REMOVE: &str = "on-remove";
pub(crate) const HOOK_BUDGET: Duration = Duration::from_secs(5);
const HOOK_OUTPUT_LIMIT: usize = 4 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookOutcome {
Ran {
output: String,
},
Refused {
code: Option<i32>,
output: String,
},
TimedOut {
after: Duration,
},
NotSpawned {
reason: String,
},
Unread {
reason: String,
},
}
impl fmt::Display for HookOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ran { .. } => f.write_str("ran its on-remove hook"),
Self::Refused {
code: Some(code), ..
} => {
write!(f, "refused the on-remove hook, exiting {code}")
}
Self::Refused { code: None, .. } => {
f.write_str("was stopped by a signal during the on-remove hook")
}
Self::TimedOut { after } => {
write!(f, "did not finish its on-remove hook within {after:?}")
}
Self::NotSpawned { reason } => write!(f, "could not be run: {reason}"),
Self::Unread { reason } => write!(
f,
"ran its on-remove hook and shep could not read the outcome: {reason}"
),
}
}
}
pub(crate) async fn run_on_remove(
binary: &Path,
home: &Path,
name: &str,
budget: Duration,
) -> HookOutcome {
let mut command = tokio::process::Command::new(binary);
command
.arg(ON_REMOVE)
.env_clear()
.envs(dog_env(home, name))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
command.process_group(0);
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return HookOutcome::NotSpawned {
reason: err.to_string(),
};
}
};
let leader = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let finished = tokio::time::timeout(budget, drain(&mut child, stdout, stderr)).await;
match finished {
Err(_elapsed) => {
kill_group(leader);
let _ = child.kill().await;
HookOutcome::TimedOut { after: budget }
}
Ok(Err(err)) => {
kill_group(leader);
let _ = child.kill().await;
HookOutcome::Unread {
reason: err.to_string(),
}
}
Ok(Ok((out, err, status))) => {
let output = format!("{}{}", capped(&out), capped(&err));
if status.success() {
HookOutcome::Ran { output }
} else {
HookOutcome::Refused {
code: status.code(),
output,
}
}
}
}
}
async fn drain(
child: &mut tokio::process::Child,
stdout: Option<tokio::process::ChildStdout>,
stderr: Option<tokio::process::ChildStderr>,
) -> std::io::Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus)> {
let (out, err) = tokio::try_join!(read_head(stdout), read_head(stderr))?;
let status = child.wait().await?;
Ok((out, err, status))
}
async fn read_head<R: tokio::io::AsyncRead + Unpin>(src: Option<R>) -> std::io::Result<Vec<u8>> {
use tokio::io::AsyncReadExt as _;
let Some(mut src) = src else {
return Ok(Vec::new());
};
let mut kept = Vec::new();
let mut chunk = [0u8; 8 * 1024];
loop {
let read = src.read(&mut chunk).await?;
if read == 0 {
return Ok(kept);
}
let room = HOOK_OUTPUT_LIMIT.saturating_sub(kept.len());
kept.extend_from_slice(&chunk[..read.min(room)]);
}
}
#[cfg(unix)]
fn kill_group(leader: Option<u32>) {
if let Some(pid) = leader.and_then(|id| i32::try_from(id).ok())
&& pid > 0
{
let group = nix::unistd::Pid::from_raw(-pid);
let _ = nix::sys::signal::kill(group, nix::sys::signal::Signal::SIGKILL);
}
}
#[cfg(windows)]
fn kill_group(_leader: Option<u32>) {}
fn capped(raw: &[u8]) -> String {
sanitise(&String::from_utf8_lossy(raw)).0
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
const BOUND: Duration = Duration::from_secs(30);
const RUN_BUDGET: Duration = Duration::from_secs(20);
const SWEEP_BUDGET: Duration = Duration::from_secs(5);
const SURVIVOR_BOUND: Duration = Duration::from_secs(5);
const SHORT_BUDGET: Duration = Duration::from_secs(1);
fn dog(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
async fn run(binary: &Path, home: &Path, budget: Duration) -> HookOutcome {
tokio::time::timeout(BOUND, run_on_remove(binary, home, "watchdog", budget))
.await
.expect("the hook runner must not outlive its own budget")
}
#[tokio::test]
async fn a_dog_that_fills_both_pipes_is_drained_rather_than_deadlocked() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(
dir.path(),
"chatty",
"printf 'out-marker\\n'\n\
printf 'err-marker\\n' >&2\n\
line=$(printf '%0256d' 0)\n\
i=0\n\
while [ \"$i\" -lt 1024 ]; do\n\
printf '%s\\n' \"$line\"\n\
printf '%s\\n' \"$line\" >&2\n\
i=$((i + 1))\n\
done",
);
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Ran { output } = &outcome else {
panic!("a dog that exits 0 after writing both pipes ran its hook, got {outcome:?}");
};
assert!(
output.contains("out-marker"),
"stdout must reach the outcome"
);
assert!(
output.contains("err-marker"),
"and so must stderr, which a single cap over the pair would cut off"
);
}
#[tokio::test]
async fn a_dog_that_does_not_know_the_argument_refuses_and_is_quoted() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(
dir.path(),
"older",
"printf 'unrecognized subcommand: %s\\n' \"$1\" >&2\nexit 2",
);
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Refused { code, output } = &outcome else {
panic!("a non-zero exit is a refusal, got {outcome:?}");
};
assert_eq!(*code, Some(2));
assert!(
output.contains("unrecognized subcommand: on-remove"),
"the dog's own words carry the reason: {output}"
);
}
#[tokio::test]
async fn the_hook_is_the_dogs_whole_argv() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(dir.path(), "echoing", "printf '%s|%s' \"$#\" \"$*\"");
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
assert_eq!(
outcome,
HookOutcome::Ran {
output: "1|on-remove".to_string()
}
);
}
#[tokio::test]
async fn a_hook_sees_the_shepherds_environment_and_not_the_operators() {
assert!(
std::env::var_os("CARGO").is_some(),
"this test reads $CARGO to prove the clear; run it under cargo"
);
let dir = tempfile::tempdir().unwrap();
let binary = dog(
dir.path(),
"nosy",
"printf '%s|%s|%s' \"$SHEP_HOME\" \"$SHEP_DOG_NAME\" \"${CARGO:-cleared}\"",
);
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Ran { output } = &outcome else {
panic!("expected a clean run, got {outcome:?}");
};
assert_eq!(
*output,
format!("{}|watchdog|cleared", dir.path().display()),
"the home and the name reach the dog; nothing else does"
);
}
#[tokio::test]
async fn a_dog_that_never_finishes_is_killed_at_the_budget() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(dir.path(), "wedged", "sleep 600");
let started = std::time::Instant::now();
let outcome = run(&binary, dir.path(), SHORT_BUDGET).await;
assert_eq!(
outcome,
HookOutcome::TimedOut {
after: SHORT_BUDGET
}
);
assert!(
started.elapsed() < BOUND,
"the budget, not the test's own bound, is what ended it"
);
}
#[tokio::test]
async fn a_fork_the_dog_left_behind_is_swept_with_it() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(
dir.path(),
"forking",
"( : > \"$SHEP_HOME/grandchild-started\"\n\
while kill -0 $$ 2>/dev/null; do sleep 0.1; done\n\
: > \"$SHEP_HOME/grandchild\" ) &\n\
sleep 600",
);
let announced = dir.path().join("grandchild-started");
let survivor = dir.path().join("grandchild");
let outcome = run(&binary, dir.path(), SWEEP_BUDGET).await;
assert_eq!(
outcome,
HookOutcome::TimedOut {
after: SWEEP_BUDGET
}
);
assert!(
announced.exists(),
"the dog never forked the grandchild inside the budget, so the sweep was never tested"
);
let survivor_seen = async {
loop {
if survivor.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
};
let survivor_written = tokio::time::timeout(SURVIVOR_BOUND, survivor_seen).await;
assert!(
survivor_written.is_err(),
"a fork outliving the dog kept running after the budget killed it"
);
}
#[tokio::test]
async fn a_dogs_output_cannot_drive_the_terminal() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(dir.path(), "escaping", "printf 'clean\\033[2Jgone'");
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Ran { output } = &outcome else {
panic!("expected a clean run, got {outcome:?}");
};
assert!(
!output.contains('\u{1b}'),
"the escape must be stripped: {output:?}"
);
assert!(
output.contains("clean") && output.contains("[2Jgone"),
"and its printable tail must survive as inert text: {output:?}"
);
}
#[tokio::test]
async fn a_dog_a_signal_stopped_has_no_exit_code() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(dir.path(), "crashing", "printf 'dying' >&2\nkill -ABRT $$");
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Refused { code, output } = &outcome else {
panic!("a signalled dog is a refusal, got {outcome:?}");
};
assert_eq!(*code, None, "a signal leaves no exit code");
assert_eq!(output, "dying", "and what it managed to say still arrives");
assert_eq!(
outcome.to_string(),
"was stopped by a signal during the on-remove hook"
);
}
#[tokio::test]
async fn a_binary_that_is_not_there_is_not_a_refusal() {
let dir = tempfile::tempdir().unwrap();
let outcome = run(&dir.path().join("never-installed"), dir.path(), RUN_BUDGET).await;
assert!(
matches!(outcome, HookOutcome::NotSpawned { .. }),
"a missing binary is its own outcome, got {outcome:?}"
);
}
#[tokio::test]
async fn each_stream_keeps_its_own_head() {
let dir = tempfile::tempdir().unwrap();
let binary = dog(
dir.path(),
"lopsided",
"line=$(printf '%08192d' 0)\n\
printf '%s' \"$line\"\n\
printf 'err-marker' >&2",
);
let outcome = run(&binary, dir.path(), RUN_BUDGET).await;
let HookOutcome::Ran { output } = &outcome else {
panic!("expected a clean run, got {outcome:?}");
};
assert_eq!(
output.len(),
HOOK_OUTPUT_LIMIT + "err-marker".len(),
"stdout is cut to the cap and stderr arrives whole after it"
);
assert!(output.ends_with("err-marker"), "{output}");
}
}