#[cfg(test)]
use std::io::BufRead;
use std::io::IsTerminal;
use std::io::Write as _;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::error::{MindError, Result};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookEnv {
pub profiles: Vec<String>,
pub item_profiles: Vec<String>,
}
impl HookEnv {
pub fn vars(&self) -> [(&'static str, String); 2] {
let join = |v: &[String]| {
let set: std::collections::BTreeSet<&str> = v.iter().map(String::as_str).collect();
set.into_iter().collect::<Vec<_>>().join(",")
};
[
("MIND_PROFILES", join(&self.profiles)),
("MIND_ITEM_PROFILES", join(&self.item_profiles)),
]
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Accept {
pub install: bool,
pub update: bool,
pub uninstall: bool,
pub build: bool,
}
impl Accept {
pub const ALL: Accept = Accept {
install: true,
update: true,
uninstall: true,
build: true,
};
pub fn event(self, event: crate::mindfile::HookEvent) -> bool {
use crate::mindfile::HookEvent;
match event {
HookEvent::Install => self.install,
HookEvent::Update => self.update,
HookEvent::Uninstall => self.uninstall,
}
}
pub fn event_str(self, event: &str) -> bool {
match event {
"install" => self.install,
"update" => self.update,
"uninstall" => self.uninstall,
"build" => self.build,
_ => false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ProfileScope {
pub source: String,
pub enabled: Vec<String>,
pub reasons: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
}
thread_local! {
static PROFILE_SCOPE: std::cell::RefCell<Option<ProfileScope>> =
const { std::cell::RefCell::new(None) };
}
pub struct ProfileScopeGuard(Option<ProfileScope>);
impl Drop for ProfileScopeGuard {
fn drop(&mut self) {
let prev = self.0.take();
PROFILE_SCOPE.with(|c| *c.borrow_mut() = prev);
}
}
pub fn scope_profiles(scope: ProfileScope) -> ProfileScopeGuard {
let prev = PROFILE_SCOPE.with(|c| c.borrow_mut().replace(scope));
ProfileScopeGuard(prev)
}
pub fn scoped_profiles(source: &str) -> Option<Vec<String>> {
PROFILE_SCOPE.with(|c| {
c.borrow()
.as_ref()
.filter(|s| s.source == source)
.map(|s| s.enabled.clone())
})
}
pub fn scoped_item_reasons(source: &str, key: &str) -> Vec<String> {
PROFILE_SCOPE.with(|c| {
c.borrow()
.as_ref()
.filter(|s| s.source == source)
.and_then(|s| s.reasons.get(key))
.map(|r| r.iter().cloned().collect())
.unwrap_or_default()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookChoice {
RunAndContinue,
SkipAndContinue,
Abort,
}
const HOOK_CHOICES: [crate::prompt::Choice<HookChoice>; 3] = [
crate::prompt::Choice {
keys: &["y", "yes"],
value: HookChoice::RunAndContinue,
},
crate::prompt::Choice {
keys: &["n", "no"],
value: HookChoice::SkipAndContinue,
},
crate::prompt::Choice {
keys: &["a", "abort"],
value: HookChoice::Abort,
},
];
static NON_INTERACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn force_non_interactive() {
NON_INTERACTIVE.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn is_tty() -> bool {
if NON_INTERACTIVE.load(std::sync::atomic::Ordering::Relaxed) {
return false;
}
match std::env::var_os("MIND_TTY") {
Some(v) => tty_override(&v),
None => std::io::stdin().is_terminal(),
}
}
fn tty_override(value: &std::ffi::OsStr) -> bool {
!matches!(
value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
pub fn disclosure_text(
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let identity_clean = crate::sanitize::strip_ansi(identity);
let mut out = String::new();
out.push_str("====== hook: ");
out.push_str(&identity_clean);
out.push_str(" ======\n");
out.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
declared_override,
browse_url,
));
out
}
const HOOK_QUESTION: &str = "Run this hook? [Y/n/a] (default Y): ";
#[cfg(test)]
fn read_choice<R: BufRead>(mut reader: R) -> Result<HookChoice> {
crate::prompt::ask_from(
&mut reader,
&mut std::io::sink(),
"",
&HOOK_CHOICES,
HookChoice::RunAndContinue,
)
}
pub fn prompt_choice(disclosure: &str) -> Result<HookChoice> {
crate::prompt::guard("running a hook")?;
crate::progress::suspend(|| {
print!("{disclosure}");
println!(" [Y] run the hook [n] skip it [a] abort - install nothing");
let stdin = std::io::stdin();
crate::prompt::ask_from(
&mut stdin.lock(),
&mut std::io::stdout(),
HOOK_QUESTION,
&HOOK_CHOICES,
HookChoice::RunAndContinue,
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionalChoice {
Run,
Skip,
}
const OPTIONAL_CHOICES: [crate::prompt::Choice<OptionalChoice>; 2] = [
crate::prompt::Choice {
keys: &["y", "yes"],
value: OptionalChoice::Run,
},
crate::prompt::Choice {
keys: &["n", "no"],
value: OptionalChoice::Skip,
},
];
#[cfg(test)]
fn read_optional_choice<R: BufRead>(mut reader: R) -> Result<OptionalChoice> {
crate::prompt::ask_from(
&mut reader,
&mut std::io::sink(),
"",
&OPTIONAL_CHOICES,
OptionalChoice::Run,
)
}
pub fn prompt_choice_optional(disclosure: &str) -> Result<OptionalChoice> {
crate::prompt::guard("running a hook")?;
crate::progress::suspend(|| {
print!("{disclosure}");
println!(" [Y] run [n] skip");
let stdin = std::io::stdin();
crate::prompt::ask_from(
&mut stdin.lock(),
&mut std::io::stdout(),
"Run this optional hook? [Y/n] (default Y): ",
&OPTIONAL_CHOICES,
OptionalChoice::Run,
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookAct {
Run,
Skip,
Abort,
}
pub fn decide(
key: &crate::consent::HookKey,
disclosure: &str,
optional: bool,
accepted: bool,
) -> Result<HookAct> {
if accepted {
return Ok(HookAct::Run);
}
if !is_tty() {
return Ok(HookAct::Skip);
}
match crate::consent::answer_for(key) {
Some(crate::consent::Answer::RunAll) => return Ok(HookAct::Run),
Some(crate::consent::Answer::SkipAll) => return Ok(HookAct::Skip),
Some(crate::consent::Answer::Review) | None => {}
}
if optional {
return Ok(match prompt_choice_optional(disclosure)? {
OptionalChoice::Run => HookAct::Run,
OptionalChoice::Skip => HookAct::Skip,
});
}
Ok(match prompt_choice(disclosure)? {
HookChoice::RunAndContinue => HookAct::Run,
HookChoice::SkipAndContinue => HookAct::Skip,
HookChoice::Abort => HookAct::Abort,
})
}
#[allow(clippy::too_many_arguments)]
pub fn hook_disclosure_text(
label: &str,
event: &'static str,
optional: bool,
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let label_clean = crate::sanitize::strip_ansi(label);
let kind = if optional { "optional" } else { "required" };
let mut out = String::new();
out.push_str("====== hook: ");
out.push_str(&label_clean);
out.push_str(" ======\n");
out.push_str(" Hook: ");
out.push_str(&label_clean);
out.push_str(" (");
out.push_str(kind);
out.push_str(")\n");
out.push_str(" Event: ");
out.push_str(event);
out.push('\n');
out.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
declared_override,
browse_url,
));
out
}
fn disclosure_body(
identity: &str,
pin_desc: &str,
commit: &str,
clone_path: &str,
command: &str,
declared_override: Option<&str>,
browse_url: Option<&str>,
) -> String {
let identity = crate::sanitize::strip_ansi(identity);
let pin_desc = crate::sanitize::strip_ansi(pin_desc);
let commit = crate::sanitize::strip_ansi(commit);
let clone_path = crate::sanitize::strip_ansi(clone_path);
let command = crate::sanitize::strip_ansi(command);
let declared_override_owned: Option<String> =
declared_override.map(crate::sanitize::strip_ansi);
let declared_override = declared_override_owned.as_deref();
let browse_url_owned: Option<String> = browse_url.map(crate::sanitize::strip_ansi);
let browse_url = browse_url_owned.as_deref();
let mut out = String::new();
out.push_str(" Source: ");
out.push_str(&identity);
out.push('\n');
out.push_str(" Pin: ");
out.push_str(&pin_desc);
out.push('\n');
out.push_str(" Commit: ");
out.push_str(&commit);
out.push('\n');
out.push_str(" Clone: ");
out.push_str(&clone_path);
out.push('\n');
if let Some(url) = browse_url {
out.push_str(" Browse: ");
out.push_str(url);
out.push('\n');
}
if let Some(declared) = declared_override {
out.push_str(" Declared: ");
out.push_str(declared);
out.push('\n');
out.push_str(" Override: ");
out.push_str(&command);
out.push('\n');
out.push_str(" NOTE: the user-supplied command replaces the source's declared command.\n");
} else {
out.push_str(" Command: ");
out.push_str(&command);
out.push('\n');
}
out.push('\n');
out.push_str(" WARNING: this executes arbitrary code from the source with your privileges.\n");
out
}
pub fn apply_hook_override(
resolved: Vec<crate::mindfile::ResolvedHook>,
supplied: Option<&str>,
event: crate::mindfile::HookEvent,
) -> (Vec<crate::mindfile::ResolvedHook>, Option<Vec<String>>) {
use crate::mindfile::ResolvedHook;
let effective = match supplied.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s,
None => return (resolved, None),
};
let mut replaced: Vec<String> = Vec::new();
let mut others: Vec<ResolvedHook> = Vec::new();
for hook in resolved {
if hook.event == event {
replaced.push(hook.run);
} else {
others.push(hook);
}
}
let override_hook = ResolvedHook {
run: effective.to_owned(),
name: None,
optional: false,
event,
};
let mut result = Vec::with_capacity(1 + others.len());
result.push(override_hook);
result.extend(others);
let replaced = if replaced.is_empty() {
None
} else {
Some(replaced)
};
(result, replaced)
}
pub fn apply_install_override(
resolved: Vec<crate::mindfile::ResolvedHook>,
supplied: Option<&str>,
) -> (Vec<crate::mindfile::ResolvedHook>, Option<Vec<String>>) {
apply_hook_override(resolved, supplied, crate::mindfile::HookEvent::Install)
}
pub fn run_hook(
command: &str,
clone_dir: &Path,
identity: &str,
event: &'static str,
label: &str,
env: &HookEnv,
site: crate::hook_log::Site,
) -> Result<()> {
crate::progress::suspend(|| {
run_hook_framed(command, clone_dir, identity, event, label, env, site)
})
}
fn run_hook_framed(
command: &str,
clone_dir: &Path,
identity: &str,
event: &'static str,
label: &str,
env: &HookEnv,
site: crate::hook_log::Site,
) -> Result<()> {
use crate::hook_log::Outcome;
let label = crate::sanitize::strip_ansi(label);
let command_clean = crate::sanitize::strip_ansi(command);
let mut record = crate::hook_log::base(identity, site, event, &label, command, Outcome::Ok);
let log = crate::hook_log::open_log(identity, site, event, &label, command);
record.log = log.as_ref().map(|l| l.path.display().to_string());
let _ = std::io::stdout().flush();
println!("====== (hook: {label}) ======");
let _ = std::io::stdout().flush();
let started = std::time::Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.envs(env.vars())
.env(crate::lock::IN_HOOK_ENV, "1")
.current_dir(clone_dir)
.stdin(Stdio::null());
let sigint = interrupt::catch();
let spawned = match log {
Some(log) => run_teed(&mut cmd, log, started),
None => cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map(|s| (s, started.elapsed())),
};
let interrupted = sigint.taken();
drop(sigint);
let (status, ran_for) = match spawned {
Ok(done) => done,
Err(e) => {
println!("====== (end hook: {label}) ======");
if let Some(path) = &record.log
&& let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(path)
{
let _ = writeln!(f, "# not started: {e}");
}
record.outcome = Outcome::Failed;
record.error = Some(e.to_string());
crate::hook_log::file(record);
return Err(MindError::HookFailed {
event,
label: label.clone(),
identity: identity.to_string(),
command: command_clean.clone(),
status: None,
reason: e.to_string(),
streamed: false,
});
}
};
record.duration_ms = Some(ran_for.as_millis().min(u128::from(u64::MAX)) as u64);
record.exit_code = status.code();
record.signal = exit_signal(&status);
if interrupted {
record.outcome = Outcome::Interrupted;
} else if !status.success() {
record.outcome = Outcome::Failed;
}
if record.outcome == Outcome::Ok
&& crate::hook_log::keeps_failed_only()
&& let Some(path) = record.log.take()
{
crate::hook_log::discard_log(Path::new(&path));
}
println!(
"====== (end hook: {label}, {}) ======",
record.status_text()
);
if let Some(path) = &record.log {
crate::render::note(format!("note: hook output saved to {path}"));
}
crate::hook_log::file(record);
if interrupted {
return Err(MindError::HookInterrupted {
event,
label,
identity: identity.to_string(),
});
}
if status.success() {
Ok(())
} else {
Err(MindError::HookFailed {
event,
label,
identity: identity.to_string(),
command: command_clean,
status: Some(status),
reason: String::new(),
streamed: true,
})
}
}
const TEE_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
fn run_teed(
cmd: &mut Command,
log: crate::hook_log::Log,
started: std::time::Instant,
) -> std::io::Result<(std::process::ExitStatus, std::time::Duration)> {
use std::io::Read;
use std::sync::{Arc, Mutex, mpsc};
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let file = Arc::new(Mutex::new(log.file));
let (done_tx, done_rx) = mpsc::channel::<()>();
let spawn_copier = |mut src: Box<dyn Read + Send>, tag: &'static str, to_stderr: bool| {
let file = Arc::clone(&file);
let done = done_tx.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut line: Vec<u8> = Vec::new();
let write_line = |line: &[u8]| {
let mut f = file.lock().unwrap_or_else(|e| e.into_inner());
let _ = f.write_all(tag.as_bytes());
let _ = f.write_all(line);
let _ = f.write_all(b"\n");
};
loop {
let n = match src.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let chunk = &buf[..n];
if to_stderr {
let mut e = std::io::stderr().lock();
let _ = e.write_all(chunk);
let _ = e.flush();
} else {
let mut o = std::io::stdout().lock();
let _ = o.write_all(chunk);
let _ = o.flush();
}
split_log_lines(&mut line, chunk, &write_line);
}
if !line.is_empty() {
write_line(&line);
}
let _ = done.send(());
})
};
let mut copiers = 0;
if let Some(out) = child.stdout.take() {
spawn_copier(Box::new(out), "out | ", false);
copiers += 1;
}
if let Some(err) = child.stderr.take() {
spawn_copier(Box::new(err), "err | ", true);
copiers += 1;
}
drop(done_tx);
let status = child.wait()?;
let ran_for = started.elapsed();
let deadline = std::time::Instant::now() + TEE_GRACE;
for _ in 0..copiers {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if done_rx.recv_timeout(left).is_err() {
break;
}
}
let mut f = file.lock().unwrap_or_else(|e| e.into_inner());
let end = match (status.code(), exit_signal(&status)) {
(Some(code), _) => format!("exit {code}"),
(None, Some(sig)) => format!("signal {sig}"),
(None, None) => "exited".to_string(),
};
let _ = writeln!(f, "# {end}");
Ok((status, ran_for))
}
const LOG_LINE_MAX: usize = 64 * 1024;
fn split_log_lines(line: &mut Vec<u8>, chunk: &[u8], mut emit: impl FnMut(&[u8])) {
for &b in chunk {
if b == b'\n' {
emit(line);
line.clear();
} else {
line.push(b);
if line.len() >= LOG_LINE_MAX {
emit(line);
line.clear();
}
}
}
}
mod interrupt {
use std::sync::atomic::{AtomicBool, Ordering};
static HIT: AtomicBool = AtomicBool::new(false);
pub struct Guard {
#[cfg(unix)]
old: Option<libc::sigaction>,
}
#[cfg(unix)]
extern "C" fn on_sigint(_: libc::c_int) {
if HIT.swap(true, Ordering::SeqCst) {
unsafe {
libc::signal(libc::SIGINT, libc::SIG_DFL);
libc::raise(libc::SIGINT);
}
}
}
#[cfg(not(unix))]
pub fn catch() -> Guard {
HIT.store(false, Ordering::SeqCst);
Guard {}
}
#[cfg(unix)]
pub fn catch() -> Guard {
HIT.store(false, Ordering::SeqCst);
unsafe {
let mut old: libc::sigaction = std::mem::zeroed();
if libc::sigaction(libc::SIGINT, std::ptr::null(), &mut old) != 0
|| old.sa_sigaction == libc::SIG_IGN
{
return Guard { old: None };
}
let mut new: libc::sigaction = std::mem::zeroed();
new.sa_sigaction = on_sigint as extern "C" fn(libc::c_int) as libc::sighandler_t;
libc::sigemptyset(&mut new.sa_mask);
new.sa_flags = libc::SA_RESTART;
if libc::sigaction(libc::SIGINT, &new, std::ptr::null_mut()) != 0 {
return Guard { old: None };
}
Guard { old: Some(old) }
}
}
impl Guard {
pub fn taken(&self) -> bool {
HIT.load(Ordering::SeqCst)
}
}
impl Drop for Guard {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(old) = &self.old {
unsafe {
libc::sigaction(libc::SIGINT, old, std::ptr::null_mut());
}
}
}
}
}
#[cfg(unix)]
fn exit_signal(status: &std::process::ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
status.signal()
}
#[cfg(not(unix))]
fn exit_signal(_: &std::process::ExitStatus) -> Option<i32> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn decide_accepted_runs_and_non_tty_skips() {
let key = crate::consent::HookKey::source_hook("s", "install", "make");
assert_eq!(decide(&key, "d", false, true).unwrap(), HookAct::Run);
assert_eq!(decide(&key, "d", true, true).unwrap(), HookAct::Run);
assert_eq!(decide(&key, "d", false, false).unwrap(), HookAct::Skip);
assert_eq!(decide(&key, "d", true, false).unwrap(), HookAct::Skip);
}
#[test]
fn accept_gates_each_event_on_its_own() {
use crate::mindfile::HookEvent;
let none = Accept::default();
for e in [HookEvent::Install, HookEvent::Update, HookEvent::Uninstall] {
assert!(!none.event(e));
assert!(Accept::ALL.event(e));
}
let update = Accept {
update: true,
..Accept::default()
};
assert!(update.event(HookEvent::Update));
assert!(!update.event(HookEvent::Install));
assert!(!update.event(HookEvent::Uninstall));
assert!(update.event_str("update"));
assert!(!update.event_str("install"));
assert!(!update.event_str("build"));
assert!(
Accept {
build: true,
..Accept::default()
}
.event_str("build")
);
assert!(!Accept::ALL.event_str("deploy"));
}
#[test]
fn tty_override_reads_a_boolean() {
use std::ffi::OsStr;
for falsey in ["", "0", "false", "FALSE", "no", "off", " 0 "] {
assert!(
!tty_override(OsStr::new(falsey)),
"{falsey:?} must read as not-a-terminal"
);
}
for truthy in ["1", "true", "TRUE", "yes", "on", "anything"] {
assert!(
tty_override(OsStr::new(truthy)),
"{truthy:?} must read as a terminal"
);
}
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"mind-hook-test-{}-{}-{n}",
std::process::id(),
tag
));
fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn read_choice_accepts_each_reply_in_any_case() {
let read = |s: &str| read_choice(std::io::Cursor::new(s.to_string())).unwrap();
assert_eq!(read("Y\n"), HookChoice::RunAndContinue);
assert_eq!(read(" yes \n"), HookChoice::RunAndContinue);
assert_eq!(read("N\n"), HookChoice::SkipAndContinue);
assert_eq!(read("\ta\n"), HookChoice::Abort);
}
#[test]
fn read_choice_asks_again_after_an_unrecognized_reply() {
let reader = std::io::Cursor::new("garbage\n1\nn\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::SkipAndContinue);
}
#[test]
fn read_choice_eof_takes_the_default() {
let reader = std::io::Cursor::new("");
let result = read_choice(reader).expect("read_choice should not error on EOF");
assert_eq!(result, HookChoice::RunAndContinue);
}
#[test]
fn read_choice_run_and_continue_on_y() {
let reader = std::io::Cursor::new("y\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::RunAndContinue);
}
#[test]
fn read_choice_skip_and_continue_on_n() {
let reader = std::io::Cursor::new("n\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::SkipAndContinue);
}
#[test]
fn read_choice_run_and_continue_on_bare_newline() {
let reader = std::io::Cursor::new("\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(
result,
HookChoice::RunAndContinue,
"bare Enter must yield RunAndContinue (default Y)"
);
}
#[test]
fn read_choice_abort_on_a() {
let reader = std::io::Cursor::new("a\n");
let result = read_choice(reader).expect("read_choice should not error");
assert_eq!(result, HookChoice::Abort);
}
#[test]
fn read_optional_choice_asks_again_after_an_unrecognized_reply() {
let reader = std::io::Cursor::new("garbage\n a \n N \n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(
result,
OptionalChoice::Skip,
"`a` is not an optional-hook reply"
);
}
#[test]
fn read_optional_choice_run_on_y() {
let reader = std::io::Cursor::new("y\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(result, OptionalChoice::Run);
}
#[test]
fn read_optional_choice_skip_on_n() {
let reader = std::io::Cursor::new("n\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(result, OptionalChoice::Skip);
}
#[test]
fn read_optional_choice_eof_takes_the_default() {
let reader = std::io::Cursor::new("");
let result = read_optional_choice(reader).expect("no error on EOF");
assert_eq!(result, OptionalChoice::Run);
}
#[test]
fn read_optional_choice_run_on_bare_newline() {
let reader = std::io::Cursor::new("\n");
let result = read_optional_choice(reader).expect("no error");
assert_eq!(
result,
OptionalChoice::Run,
"bare Enter must yield Run (default Y)"
);
}
#[test]
fn disclosure_text_contains_separator_header() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
None,
);
assert!(
text.starts_with("====== hook: github.com/acme/tools ======\n"),
"disclosure_text must start with the separator header; got: {text}"
);
}
#[test]
fn disclosure_text_contains_required_fields() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
None,
);
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("main"), "missing pin_desc");
assert!(text.contains("abc1234"), "missing commit");
assert!(
text.contains("/home/user/.mind/sources/github.com/acme/tools"),
"missing clone_path"
);
assert!(text.contains("make install"), "missing command");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn disclosure_text_override_shows_both_commands_and_replacement_note() {
let text = disclosure_text(
"github.com/acme/tools",
"v1.0",
"def5678",
"/tmp/clone",
"./user-custom.sh",
Some("make install"),
None,
);
assert!(text.contains("make install"), "missing declared command");
assert!(
text.contains("./user-custom.sh"),
"missing override command"
);
assert!(
text.contains("replaces"),
"missing replacement statement; text: {text}"
);
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn disclosure_text_no_override_does_not_mention_replacement() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
!text.contains("replaces"),
"should not mention replacement when no override"
);
}
#[test]
fn disclosure_text_matches_header_plus_disclosure_body() {
let identity = "github.com/acme/tools";
let pin_desc = "main";
let commit = "abc1234";
let clone_path = "/home/user/.mind/sources/github.com/acme/tools";
let command = "make install";
let via_fn = disclosure_text(identity, pin_desc, commit, clone_path, command, None, None);
let mut expected = String::new();
expected.push_str("====== hook: ");
expected.push_str(identity);
expected.push_str(" ======\n");
expected.push_str(&disclosure_body(
identity, pin_desc, commit, clone_path, command, None, None,
));
assert_eq!(
via_fn, expected,
"disclosure_text output must equal header + disclosure_body"
);
}
#[test]
fn disclosure_sanitizes_ansi_and_bidi_in_source_fields() {
let ansi_command = "\x1b[2K\x1b[1A rm -rf / \x1b[0m";
let bidi_identity = "github.com/\u{202E}kcal/evil\u{202E}";
let text = disclosure_text(
bidi_identity,
"main",
"abc1234",
"/tmp/clone",
ansi_command,
None,
None,
);
assert!(
!text.contains('\x1b'),
"disclosure_text must not contain raw ESC byte; got: {text:?}"
);
assert!(
!text.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"disclosure_text must not contain bidi-override code points; got: {text:?}"
);
let ansi_label = "\x1b[31mbuild\x1b[0m\u{202E}";
let text2 = hook_disclosure_text(
ansi_label,
"install",
false,
bidi_identity,
"main",
"abc1234",
"/tmp/clone",
ansi_command,
None,
None,
);
assert!(
!text2.contains('\x1b'),
"hook_disclosure_text must not contain raw ESC byte; got: {text2:?}"
);
assert!(
!text2.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"hook_disclosure_text must not contain bidi-override code points; got: {text2:?}"
);
let text3 = disclosure_text(
bidi_identity,
"v1.0",
"def5678",
"/tmp/clone",
ansi_command,
Some("\x1b[32m malicious_decl \x1b[0m"),
None,
);
assert!(
!text3.contains('\x1b'),
"disclosure with override must not contain raw ESC byte; got: {text3:?}"
);
let ansi_browse_url = "\x1b[34mhttps://github.com/evil/repo/tree/abc\u{202E}1234\x1b[0m";
let text4 = disclosure_text(
"github.com/evil/repo",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
Some(ansi_browse_url),
);
assert!(
!text4.contains('\x1b'),
"disclosure with browse_url must not contain raw ESC byte; got: {text4:?}"
);
assert!(
!text4.chars().any(|c| matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)),
"disclosure with browse_url must not contain bidi-override code points; got: {text4:?}"
);
}
#[test]
fn disclosure_text_matches_header_plus_disclosure_body_with_override() {
let identity = "github.com/acme/tools";
let pin_desc = "v1.0";
let commit = "def5678";
let clone_path = "/tmp/clone";
let command = "./user-custom.sh";
let declared = "make install";
let via_fn = disclosure_text(
identity,
pin_desc,
commit,
clone_path,
command,
Some(declared),
None,
);
let mut expected = String::new();
expected.push_str("====== hook: ");
expected.push_str(identity);
expected.push_str(" ======\n");
expected.push_str(&disclosure_body(
identity,
pin_desc,
commit,
clone_path,
command,
Some(declared),
None,
));
assert_eq!(
via_fn, expected,
"disclosure_text with override must equal header + disclosure_body"
);
}
#[test]
fn disclosure_browse_url_line_present_when_some_absent_when_none() {
let with_url = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
Some("https://github.com/acme/tools/tree/abc1234"),
);
assert!(
with_url.contains("Browse:"),
"disclosure must contain Browse: line when browse_url is Some; got: {with_url}"
);
assert!(
with_url.contains("https://github.com/acme/tools/tree/abc1234"),
"disclosure must contain the browse URL; got: {with_url}"
);
let without_url = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
!without_url.contains("Browse:"),
"disclosure must not contain Browse: line when browse_url is None; got: {without_url}"
);
}
#[test]
fn disclosure_browse_line_positioned_after_clone() {
let text = disclosure_text(
"github.com/acme/tools",
"main",
"abc1234",
"/home/user/.mind/sources/github.com/acme/tools",
"make install",
None,
Some("https://github.com/acme/tools/tree/abc1234"),
);
let clone_at = text.find(" Clone: ").expect("Clone line present");
let browse_at = text.find(" Browse: ").expect("Browse line present");
assert!(
browse_at > clone_at,
"Browse: line must come after Clone: line; got: {text}"
);
let between = &text[clone_at..browse_at];
assert_eq!(
between.matches('\n').count(),
1,
"Browse: must be the line immediately after Clone:; got between: {between:?}"
);
let warn_at = text
.find("WARNING")
.or_else(|| text.find("make install"))
.expect("consent block present");
assert!(
browse_at < warn_at,
"Browse: line must precede the command/WARNING consent block; got: {text}"
);
}
#[test]
fn hook_disclosure_text_has_separator_header_with_label() {
let text = hook_disclosure_text(
"Build step",
"install",
true,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
text.starts_with("====== hook: Build step ======\n"),
"hook_disclosure_text must start with '====== hook: <label> ======'; got: {text}"
);
}
#[test]
fn hook_disclosure_text_names_the_lifecycle_event() {
for event in ["install", "update", "uninstall"] {
let text = hook_disclosure_text(
"Build step",
event,
false,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(
text.contains(&format!("Event: {event}")),
"the disclosure must name the {event} event: {text}"
);
}
}
#[test]
fn hook_disclosure_text_optional_contains_label_and_optional_marker() {
let text = hook_disclosure_text(
"Build step",
"install",
true,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"make install",
None,
None,
);
assert!(text.contains("Build step"), "missing label");
assert!(text.contains("optional"), "missing optional marker");
assert!(!text.contains("required"), "should not say required");
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("make install"), "missing command");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn hook_disclosure_text_required_contains_required_marker() {
let text = hook_disclosure_text(
"setup.sh",
"install",
false,
"github.com/acme/tools",
"v1.0",
"def5678",
"/tmp/clone",
"setup.sh",
None,
None,
);
assert!(text.contains("setup.sh"), "missing label/command");
assert!(text.contains("required"), "missing required marker");
assert!(text.contains("github.com/acme/tools"), "missing identity");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn hook_disclosure_text_override_shows_both_commands() {
let text = hook_disclosure_text(
"custom.sh",
"install",
false,
"github.com/acme/tools",
"main",
"abc1234",
"/tmp/clone",
"./user-custom.sh",
Some("make install"),
None,
);
assert!(text.contains("make install"), "missing declared command");
assert!(
text.contains("./user-custom.sh"),
"missing override command"
);
assert!(text.contains("replaces"), "missing replacement note");
assert!(text.contains("arbitrary"), "missing arbitrary-code warning");
}
#[test]
fn apply_install_override_none_supplied_returns_unchanged() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
}];
let (result, replaced) = apply_install_override(hooks.clone(), None);
assert_eq!(result, hooks, "hooks must be unchanged");
assert!(replaced.is_none(), "no override => replaced is None");
}
#[test]
fn apply_install_override_empty_supplied_returns_unchanged() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
}];
let (result, replaced) = apply_install_override(hooks.clone(), Some(""));
assert_eq!(result, hooks, "empty supplied => unchanged");
assert!(replaced.is_none());
let (result2, replaced2) = apply_install_override(hooks.clone(), Some(" "));
assert_eq!(result2, hooks, "whitespace supplied => unchanged");
assert!(replaced2.is_none());
}
#[test]
fn apply_install_override_replaces_declared_install_and_returns_them() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "make build".into(),
name: Some("Build".into()),
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
];
let (result, replaced) = apply_install_override(hooks, Some("./custom.sh"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].run, "./custom.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert!(!result[0].optional);
assert_eq!(result[0].name, None);
let replaced = replaced.expect("should be Some when install hooks were declared");
assert_eq!(replaced, vec!["make build", "make install"]);
}
#[test]
fn apply_install_override_uninstall_hooks_survive_in_order() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "make install".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "first-uninstall".into(),
name: Some("First".into()),
optional: false,
event: HookEvent::Uninstall,
},
ResolvedHook {
run: "second-uninstall".into(),
name: None,
optional: true,
event: HookEvent::Uninstall,
},
];
let (result, replaced) = apply_install_override(hooks, Some("./override.sh"));
assert_eq!(result[0].run, "./override.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert_eq!(result[1].run, "first-uninstall");
assert_eq!(result[1].event, HookEvent::Uninstall);
assert_eq!(result[2].run, "second-uninstall");
assert_eq!(result[2].event, HookEvent::Uninstall);
assert_eq!(result.len(), 3);
assert_eq!(replaced, Some(vec!["make install".to_string()]));
}
#[test]
fn apply_install_override_no_declared_install_adds_hook_replaced_is_none() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![ResolvedHook {
run: "teardown.sh".into(),
name: None,
optional: false,
event: HookEvent::Uninstall,
}];
let (result, replaced) = apply_install_override(hooks, Some("./new-install.sh"));
assert_eq!(result.len(), 2);
assert_eq!(result[0].run, "./new-install.sh");
assert_eq!(result[0].event, HookEvent::Install);
assert_eq!(result[1].run, "teardown.sh");
assert_eq!(result[1].event, HookEvent::Uninstall);
assert!(
replaced.is_none(),
"no declared install hooks => replaced is None even when supplied is given"
);
}
#[test]
fn apply_hook_override_uninstall_replaces_uninstall_and_keeps_install() {
use crate::mindfile::{HookEvent, ResolvedHook};
let hooks = vec![
ResolvedHook {
run: "build".into(),
name: None,
optional: false,
event: HookEvent::Install,
},
ResolvedHook {
run: "old-teardown".into(),
name: None,
optional: false,
event: HookEvent::Uninstall,
},
];
let (result, replaced) =
apply_hook_override(hooks, Some("./new-teardown.sh"), HookEvent::Uninstall);
let uninstall: Vec<&ResolvedHook> = result
.iter()
.filter(|h| h.event == HookEvent::Uninstall)
.collect();
assert_eq!(uninstall.len(), 1);
assert_eq!(uninstall[0].run, "./new-teardown.sh");
assert!(
result
.iter()
.any(|h| h.event == HookEvent::Install && h.run == "build"),
"the install hook must survive an uninstall override"
);
assert_eq!(replaced.as_deref(), Some(&["old-teardown".to_string()][..]));
}
#[test]
fn run_hook_sanitizes_ansi_in_label_and_command_for_the_error() {
let dir = TempDir::new("ansi-label");
let evil_label = "\x1b[2K\x1b[1A\x1b[2Kbuild";
let evil_command = "exit 9 # \x1b[31mcolor\x1b[0m";
let result = run_hook(
evil_command,
dir.path(),
"github.com/test/repo",
"install",
evil_label,
&HookEnv::default(),
crate::hook_log::Site::default(),
);
let err = result.expect_err("a non-zero exit must produce HookFailed");
let msg = err.to_string();
assert!(
!msg.contains('\x1b'),
"raw ESC must not appear in the rendered error: {msg:?}"
);
match err {
MindError::HookFailed {
ref label,
ref command,
..
} => {
assert!(
!label.contains('\x1b'),
"label field must not carry raw ESC: {label:?}"
);
assert!(
label.contains("build"),
"sanitized label text must remain: {label:?}"
);
assert!(
!command.contains('\x1b'),
"command field must not carry raw ESC: {command:?}"
);
assert!(
command.contains("color"),
"sanitized command text must remain: {command:?}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_spawn_failure_produces_unstreamed_hook_failed() {
let missing_dir = std::env::temp_dir().join(format!(
"mind-hook-test-missing-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
assert!(
!missing_dir.exists(),
"the dir must not exist for this test to force a spawn failure"
);
let result = run_hook(
"true",
&missing_dir,
"github.com/test/repo",
"install",
"spawn-fail",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
match result {
Err(MindError::HookFailed {
ref label,
status,
streamed,
ref reason,
..
}) => {
assert_eq!(label, "spawn-fail");
assert!(!streamed, "a spawn failure never ran the hook");
assert!(
status.is_none(),
"a process that never ran has no exit status"
);
assert!(
!reason.is_empty(),
"a spawn failure must carry its own reason, since nothing was streamed"
);
}
other => panic!("expected a spawn failure to produce HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_success_creates_marker_file() {
let dir = TempDir::new("success");
let marker = dir.path().join("marker.txt");
let marker_str = marker.to_str().expect("marker path is utf8");
let command = format!("touch {marker_str}");
let result = run_hook(
&command,
dir.path(),
"github.com/test/repo",
"install",
"test-label",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert!(
marker.exists(),
"marker file should exist after successful hook"
);
}
#[test]
fn run_hook_sets_profile_vars_sorted_and_always_present() {
let dir = TempDir::new("env");
let out = dir.path().join("env.txt");
let command = format!(
"printf '%s|%s|%s' \"$MIND_PROFILES\" \"$MIND_ITEM_PROFILES\" \
\"${{MIND_ITEM_PROFILES+set}}\" > '{}'",
out.display()
);
let env = HookEnv {
profiles: vec!["full".into(), "default".into(), "full".into()],
item_profiles: Vec::new(),
};
run_hook(
&command,
dir.path(),
"local/a/b",
"install",
"env",
&env,
crate::hook_log::Site::default(),
)
.unwrap();
let got = std::fs::read_to_string(&out).unwrap();
assert_eq!(got, "default,full||set");
}
#[test]
fn profile_scope_applies_to_its_source_and_restores_on_drop() {
assert_eq!(scoped_profiles("local/a/b"), None);
{
let mut reasons = std::collections::BTreeMap::new();
reasons.insert(
"skill:x".to_string(),
std::collections::BTreeSet::from(["full".to_string()]),
);
let _g = scope_profiles(ProfileScope {
source: "local/a/b".into(),
enabled: vec!["default".into(), "full".into()],
reasons,
});
assert_eq!(
scoped_profiles("local/a/b"),
Some(vec!["default".to_string(), "full".to_string()])
);
assert_eq!(scoped_profiles("local/other"), None);
assert_eq!(scoped_item_reasons("local/a/b", "skill:x"), vec!["full"]);
assert!(scoped_item_reasons("local/a/b", "skill:y").is_empty());
assert!(scoped_item_reasons("local/other", "skill:x").is_empty());
}
assert_eq!(scoped_profiles("local/a/b"), None);
}
#[test]
fn run_hook_nonzero_exit_returns_hook_failed() {
let dir = TempDir::new("fail");
let result = run_hook(
"exit 3",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
match result {
Err(
ref e @ MindError::HookFailed {
event,
ref label,
ref identity,
ref command,
status,
reason: ref stderr,
streamed,
},
) => {
assert_eq!(event, "install", "wrong event");
assert_eq!(label, "test-label", "wrong label");
assert_eq!(identity, "github.com/test/repo", "wrong identity");
assert_eq!(command, "exit 3", "wrong command");
assert!(
status.is_some(),
"exit status should be Some for a process that ran"
);
let code = status.unwrap().code();
assert_eq!(code, Some(3), "expected exit code 3, got {code:?}");
assert!(
stderr.is_empty(),
"an inherited hook captures no stderr to attach"
);
assert!(
streamed,
"a hook that ran streamed its own output, so the error points at the frame"
);
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"a streamed hook failure must point at the frame: {msg}"
);
assert!(
!msg.contains("see the hook"),
"must not say 'see the hook's output above' when nothing was printed: {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_with_output_before_failure_points_at_the_frame() {
let dir = TempDir::new("output-fail");
let result = run_hook(
"echo 'hook output'; exit 2",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
match result {
Err(
ref e @ MindError::HookFailed {
streamed,
reason: ref stderr,
..
},
) => {
assert!(streamed, "streamed must be true when hook produced stdout");
assert!(
stderr.is_empty(),
"stderr field must be empty (output was streamed, not captured)"
);
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"must point at the frame when output was streamed: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)' when output was printed: {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn run_hook_with_stderr_output_before_failure_points_at_the_frame() {
let dir = TempDir::new("stderr-fail");
let result = run_hook(
"echo 'hook stderr' >&2; exit 1",
dir.path(),
"github.com/test/repo",
"install",
"test-label",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
match result {
Err(ref e @ MindError::HookFailed { streamed, .. }) => {
assert!(streamed, "streamed must be true when hook produced stderr");
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"stderr output must also trigger '(see output above)': {msg}"
);
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
#[test]
fn log_lines_split_on_newlines_and_at_the_length_cap() {
let mut lines: Vec<Vec<u8>> = Vec::new();
let mut line = Vec::new();
split_log_lines(&mut line, b"a\nb", |l| lines.push(l.to_vec()));
split_log_lines(&mut line, b"c\n\nd", |l| lines.push(l.to_vec()));
assert_eq!(lines, vec![b"a".to_vec(), b"bc".to_vec(), Vec::new()]);
assert_eq!(line, b"d", "the partial line waits for more");
let mut lines: Vec<usize> = Vec::new();
let mut line = Vec::new();
let long = vec![b'x'; LOG_LINE_MAX * 2 + 5];
split_log_lines(&mut line, &long, |l| lines.push(l.len()));
assert_eq!(lines, vec![LOG_LINE_MAX, LOG_LINE_MAX]);
assert_eq!(line.len(), 5);
}
#[test]
fn run_hook_identity_and_command_propagate_to_error() {
let dir = TempDir::new("propagate");
let result = run_hook(
"false",
dir.path(),
"github.com/acme/special",
"install",
"my-hook",
&HookEnv::default(),
crate::hook_log::Site::default(),
);
match result {
Err(MindError::HookFailed {
ref identity,
ref command,
..
}) => {
assert_eq!(identity, "github.com/acme/special");
assert_eq!(command, "false");
}
other => panic!("expected HookFailed, got: {other:?}"),
}
}
}