#![cfg(unix)]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
struct Output {
stdout: String,
stderr: String,
code: i32,
}
fn termaxa(home: &Path, cwd: &Path, args: &[&str], stdin: &str) -> Output {
run_termaxa(home, cwd, args, stdin, None)
}
fn run_termaxa(
home: &Path,
cwd: &Path,
args: &[&str],
stdin: &str,
extra_path: Option<&Path>,
) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_termaxa"));
cmd.args(args)
.current_dir(cwd)
.env("TERMAXA_HOME", home)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = extra_path {
let inherited = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", format!("{}:{}", dir.display(), inherited));
}
let mut child = cmd.spawn().expect("the binary must be runnable");
let _ = child
.stdin
.take()
.expect("stdin must be piped")
.write_all(stdin.as_bytes());
let out = child.wait_with_output().expect("the child must exit");
Output {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
code: out.status.code().unwrap_or(-1),
}
}
fn scratch(tag: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!("termaxa-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("home")).expect("scratch root must be creatable");
base
}
fn project(root: &Path) -> PathBuf {
let proj = root.join("proj");
std::fs::create_dir_all(proj.join(".termaxa")).expect("project dir must be creatable");
std::fs::write(
proj.join(".termaxa").join("policy.yaml"),
"version: 1\ndefault: ask\nrules:\n - match: \"rm -rf /*\"\n action: deny\n \
- match: \"sh*\"\n action: allow\n",
)
.expect("policy must be writable");
proj
}
fn project_gating_terraform(root: &Path) -> PathBuf {
let proj = root.join("proj");
std::fs::create_dir_all(proj.join(".termaxa")).expect("project dir must be creatable");
std::fs::write(
proj.join(".termaxa").join("policy.yaml"),
"version: 1\ndefault: ask\nrules:\n - match: \"terraform destroy*\"\n action: deny\n",
)
.expect("policy must be writable");
proj
}
fn stub_terraform(bin_dir: &Path, marker: &Path) {
std::fs::create_dir_all(bin_dir).expect("stub dir must be creatable");
let path = bin_dir.join("terraform");
std::fs::write(
&path,
format!(
"#!/bin/sh\ntouch '{}'\necho 'Plan: 0 to add, 0 to change, 1 to destroy.'\n",
marker.display()
),
)
.expect("stub must be writable");
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("stub must be executable");
}
#[test]
fn a_denied_command_never_causes_the_preview_to_run_anything() {
let tmp = scratch("deny-inert");
let (home, proj) = (tmp.join("home"), project_gating_terraform(&tmp));
let bin_dir = tmp.join("bin");
let marker = tmp.join("plan-was-run");
stub_terraform(&bin_dir, &marker);
let denied = run_termaxa(
&home,
&proj,
&["check", "terraform destroy -auto-approve"],
"",
Some(&bin_dir),
);
assert!(
denied.stdout.contains("deny"),
"the fixture must actually deny: {:?}",
denied.stdout
);
assert!(
!marker.exists(),
"a denied command must not cause a subprocess"
);
run_termaxa(
&home,
&proj,
&["check", "terraform apply"],
"",
Some(&bin_dir),
);
assert!(
marker.exists(),
"an undenied command should still be previewed live"
);
}
#[test]
fn log_filters_by_decision_and_by_source() {
let tmp = scratch("log-filter");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
termaxa(&home, &proj, &["run", "--", "sh", "-c", "exit 0"], "");
let all = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(all.contains("rm -rf /"), "{all:?}");
assert!(all.contains("cat notes.txt"), "{all:?}");
let denied = termaxa(&home, &proj, &["log", "--decision", "deny"], "").stdout;
assert!(denied.contains("rm -rf /"), "{denied:?}");
assert!(
!denied.contains("cat notes.txt"),
"a filter that keeps everything is not a filter: {denied:?}"
);
let from_run = termaxa(&home, &proj, &["log", "--source", "run"], "").stdout;
assert!(from_run.contains("exit 0"), "{from_run:?}");
assert!(
!from_run.contains("cat notes.txt"),
"`check` entries are not `run` entries: {from_run:?}"
);
}
#[test]
fn the_log_says_what_became_of_each_command() {
let tmp = scratch("log-outcome");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(&home, &proj, &["run", "--", "sh", "-c", "exit 3"], "");
termaxa(&home, &proj, &["run", "--", "echo", "yes-please"], "y\n");
termaxa(&home, &proj, &["run", "--", "echo", "no-thanks"], "n\n");
let log = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(log.contains("→ exit 3"), "{log:?}");
assert!(log.contains("→ approved, exit 0"), "{log:?}");
assert!(log.contains("→ not run"), "{log:?}");
}
#[test]
fn stats_ranks_the_commands_that_were_denied() {
let tmp = scratch("stats");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-other"],
"",
);
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
let all = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(all.contains("cat notes.txt"), "{all:?}");
let stats = termaxa(&home, &proj, &["stats"], "").stdout;
assert!(stats.contains("top denied"), "{stats:?}");
assert!(
stats.contains("2× rm -rf /"),
"the same denial twice is a count of two: {stats:?}"
);
assert!(
!stats.contains("cat notes.txt"),
"what was allowed is not a denial: {stats:?}"
);
}
#[test]
fn stats_stays_quiet_about_denials_when_there_are_none() {
let tmp = scratch("stats-quiet");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
let stats = termaxa(&home, &proj, &["stats"], "").stdout;
assert!(
!stats.contains("top denied"),
"an empty ranking is a heading with nothing under it: {stats:?}"
);
}
fn take_a_backup(home: &Path, proj: &Path) -> String {
std::fs::write(proj.join("doomed.txt"), "precious\n").expect("file must be writable");
let deleted = termaxa(home, proj, &["run", "--", "rm", "doomed.txt"], "y\n");
assert_eq!(
deleted.code, 0,
"the delete itself must succeed.\nstdout: {}\nstderr: {}",
deleted.stdout, deleted.stderr
);
let listed = termaxa(home, proj, &["backups"], "").stdout;
let id = listed
.lines()
.next()
.unwrap_or_default()
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
assert!(
!id.is_empty() && !listed.contains("no backups yet"),
"the delete should have been insured.\nbackups: {listed:?}\n\
run stdout: {}\nrun stderr: {}",
deleted.stdout,
deleted.stderr
);
id
}
#[test]
fn rollback_refuses_an_id_it_does_not_have() {
let tmp = scratch("rollback-unknown");
let (home, proj) = (tmp.join("home"), project(&tmp));
take_a_backup(&home, &proj);
let out = termaxa(&home, &proj, &["rollback", "definitely-not-an-id"], "y\n");
assert_eq!(
out.code,
2,
"an unknown id is an error: {out:?}",
out = out.stderr
);
assert!(
out.stderr.contains("no backup with id"),
"and says so: {:?}",
out.stderr
);
}
#[test]
fn rollback_restores_nothing_unless_it_is_confirmed() {
let tmp = scratch("rollback-declined");
let (home, proj) = (tmp.join("home"), project(&tmp));
let id = take_a_backup(&home, &proj);
let out = termaxa(&home, &proj, &["rollback", &id], "n\n");
assert_eq!(out.code, 1, "a decline is not a success");
assert!(out.stderr.contains("rollback declined"), "{:?}", out.stderr);
assert!(
!proj.join("doomed.txt").exists(),
"declining must leave the file deleted, not restore it"
);
let out = termaxa(&home, &proj, &["rollback", &id], "y\n");
assert_eq!(
out.code, 0,
"a confirmed rollback succeeds: {:?}",
out.stderr
);
assert!(
proj.join("doomed.txt").exists(),
"the insured file must come back"
);
assert!(
out.stdout.contains("✓ 1 path(s) restored"),
"the count is the report of what happened: {:?}",
out.stdout
);
}