use serde::Serialize;
use crate::catalog::CatalogItem;
use crate::cli::HookEventArg;
use crate::error::{MindError, Result};
use crate::manifest::{InstalledItem, Manifest};
use crate::mindfile::{HookEvent, MindToml, ResolvedHook};
use crate::paths::Paths;
use crate::resolve::{
HookTarget, parse_hook_target, parse_item_ref, select_installed, source_matches_glob,
};
use crate::sanitize::strip_ansi;
use crate::source::{Pin, RecordedEvent, RecordedHook, RecordedSourceHook, Registry, Source};
fn emit_json<T: Serialize>(value: &T) -> Result<()> {
if crate::json_stdout::is_reserved() {
let s =
serde_json::to_string_pretty(value).map_err(|e| MindError::json("json output", e))?;
crate::json_stdout::record(s);
return Ok(());
}
crate::render::print_json(value)
}
#[derive(Serialize, Debug, PartialEq, Eq)]
struct HooksRunResult {
schema: u8,
action: &'static str,
target: String,
event: &'static str,
existed: usize,
ran: usize,
skipped: usize,
}
#[derive(Serialize, Debug, PartialEq)]
struct HooksListResult {
schema: u8,
action: &'static str,
target: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
sources: Vec<SourceHooksJson>,
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<ItemHooksJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct SourceHooksJson {
source: String,
hooks: Vec<SourceHookJson>,
items: Vec<ItemHooksJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct SourceHookJson {
event: &'static str,
required: bool,
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
}
#[derive(Serialize, Debug, PartialEq)]
struct ItemHooksJson {
item: String,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
hooks: Vec<ItemHookJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct ItemHookJson {
event: &'static str,
required: bool,
command: String,
}
pub fn run(
paths: &Paths,
target: &str,
event: HookEventArg,
force: bool,
dangerously_skip_install: bool,
dangerously_skip_build: bool,
) -> Result<()> {
let tally = match resolve_hook_target(paths, target)? {
HookTarget::Source(selector) => {
if event == HookEventArg::Build {
return Err(MindError::BuildEventRequiresItemTarget);
}
run_source_hooks(paths, &selector, event, force, dangerously_skip_install)?
}
HookTarget::Item(item_ref) => run_item_hooks(
paths,
&item_ref,
target,
event,
force,
dangerously_skip_install,
dangerously_skip_build,
)?,
};
if crate::render::ctx().json {
emit_json(&HooksRunResult {
schema: 1,
action: "hooks-run",
target: target.to_string(),
event: event_label(event),
existed: tally.existed,
ran: tally.ran,
skipped: tally.skipped_for_consent,
})?;
}
Ok(())
}
pub fn list(paths: &Paths, target: &str) -> Result<()> {
match resolve_hook_target(paths, target)? {
HookTarget::Source(selector) => list_source_hooks(paths, target, &selector),
HookTarget::Item(item_ref) => list_item_hooks(paths, target, &item_ref),
}
}
fn resolve_hook_target(paths: &Paths, target: &str) -> Result<HookTarget> {
let trimmed = target.trim();
if let Some(rest) = trimmed.strip_prefix("source:") {
return Ok(HookTarget::Source(rest.trim().to_string()));
}
let registry = Registry::load(paths)?;
let source_match = registry.sources.iter().any(|s| s.name == trimmed);
if source_match
&& trimmed.contains('#')
&& let Ok(item_ref) = parse_item_ref(trimmed)
{
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, &item_ref);
if !matches.is_empty() {
let item_forms: Vec<String> = matches
.iter()
.map(|it| {
format!(
"{}#{}:{}",
strip_ansi(&it.source),
it.kind.as_str(),
strip_ansi(&it.name)
)
})
.collect();
return Err(MindError::AmbiguousHookTarget {
target: trimmed.to_string(),
item_forms,
});
}
}
if source_match {
return Ok(HookTarget::Source(trimmed.to_string()));
}
parse_hook_target(target)
}
fn run_source_hooks(
paths: &Paths,
selector: &str,
event: HookEventArg,
force: bool,
dangerously_skip: bool,
) -> Result<HookTally> {
let mut registry = Registry::load(paths)?;
let indices: Vec<usize> = registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches_glob(&s.name, selector))
.map(|(i, _)| i)
.collect();
if indices.is_empty() {
return Err(MindError::SourceNotFound {
name: selector.to_string(),
});
}
let mut registry_dirty = false;
let mut existed: usize = 0;
let mut ran: usize = 0;
let mut skipped_for_consent: usize = 0;
let mut contributors: Vec<String> = Vec::new();
for idx in indices {
let source = ®istry.sources[idx];
let clone_dir = source.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
let hook_event = match event {
HookEventArg::Install => HookEvent::Install,
HookEventArg::Update => HookEvent::Update,
HookEventArg::Uninstall => HookEvent::Uninstall,
HookEventArg::Build => unreachable!("caller guards build for source targets"),
};
let event_name = event_label(event);
let hooks: Vec<&ResolvedHook> = resolved.iter().filter(|h| h.event == hook_event).collect();
if hooks.is_empty() {
crate::render::note(format!(
"note: no {event_name} hooks declared for source {}",
strip_ansi(&source.name)
));
continue;
}
let source_name = source.name.clone();
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let current = source.commit.clone();
let clone_path = clone_dir.display().to_string();
let browse_url = source.browse_url(&commit);
let mut source_contributed = false;
let mut settled: Vec<String> = Vec::new();
for h in &hooks {
if let Some(rec_event) = recorded_event(hook_event)
&& !force
&& hook_already_ran(
®istry.sources[idx],
&h.run,
rec_event,
current.as_deref(),
)
{
settled.push(strip_ansi(h.label()));
continue;
}
existed += 1;
source_contributed = true;
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.event.as_str(),
h.optional,
&source_name,
&pin_desc,
&commit,
&clone_path,
&h.run,
None,
browse_url.as_deref(),
);
match crate::hook::decide(&disclosure, h.optional, dangerously_skip)? {
crate::hook::HookAct::Run => {
ran += 1;
println!(
"running {event_name} hook '{}' for {}",
h.label(),
source_name
);
let hook_env = crate::commands::source_hook_env(®istry.sources[idx]);
if let Err(e) = crate::hook::run_hook(
&h.run,
&clone_dir,
&source_name,
event_name,
h.label(),
&hook_env,
) {
if registry_dirty {
let _ = registry.save(paths);
}
return Err(e);
}
if let Some(rec_event) = recorded_event(hook_event) {
record_hook_run(
&mut registry.sources[idx],
&h.run,
rec_event,
current.clone(),
);
registry_dirty = true;
}
}
crate::hook::HookAct::Skip => {
if !crate::hook::is_tty() {
skipped_for_consent += 1;
crate::render::note(source_skip_note(event_name, h.label(), &source_name));
} else {
crate::render::note(format!(
"note: skipped {event_name} hook '{}' for {}",
strip_ansi(h.label()),
strip_ansi(&source_name)
));
}
if let Some(rec_event) = recorded_event(hook_event) {
record_hook_run(&mut registry.sources[idx], &h.run, rec_event, None);
registry_dirty = true;
}
}
crate::hook::HookAct::Abort => {
if registry_dirty {
let _ = registry.save(paths);
}
return Err(MindError::HookAborted {
label: h.label().to_string(),
});
}
}
}
if source_contributed {
contributors.push(source_name.clone());
} else if !settled.is_empty() {
crate::render::note(nothing_pending_note(
event_name,
&settled,
&source_name,
current.as_deref(),
));
}
}
if registry_dirty {
registry.save(paths)?;
}
if existed > 0 && ran == 0 && skipped_for_consent > 0 {
return Err(MindError::HooksNotRun {
target: selector.to_string(),
event: event_label(event).to_string(),
skipped: skipped_for_consent,
resolved: contributors,
});
}
Ok(HookTally {
existed,
ran,
skipped_for_consent,
})
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct HookTally {
existed: usize,
ran: usize,
skipped_for_consent: usize,
}
impl HookTally {
fn offered(&mut self, count: usize, dangerously_skip: bool) {
self.existed += count;
if dangerously_skip {
self.ran += count;
} else if !crate::hook::is_tty() {
self.skipped_for_consent += count;
}
}
fn nothing_could_consent(self) -> bool {
self.existed > 0 && self.ran == 0 && self.skipped_for_consent > 0
}
}
fn run_item_hooks(
paths: &Paths,
item_ref: &crate::resolve::ItemRef,
target: &str,
event: HookEventArg,
force: bool,
dangerously_skip_install: bool,
dangerously_skip_build: bool,
) -> Result<HookTally> {
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, item_ref);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: item_ref.name.clone(),
});
}
let mut tally = HookTally::default();
let mut contributors: Vec<String> = Vec::new();
for installed in matches {
match event {
HookEventArg::Build => {
run_item_build(paths, installed, dangerously_skip_build)?;
}
HookEventArg::Install | HookEventArg::Update | HookEventArg::Uninstall => {
run_item_lifecycle_hooks(
paths,
installed,
event,
force,
dangerously_skip_install,
&mut tally,
&mut contributors,
)?;
}
}
}
if tally.nothing_could_consent() {
let resolved = if contributors.len() == 1 {
if crate::resolve::is_glob(target) {
contributors
} else {
vec![target.to_string()]
}
} else {
contributors
};
return Err(MindError::HooksNotRun {
target: target.to_string(),
event: event_label(event).to_string(),
skipped: tally.skipped_for_consent,
resolved,
});
}
Ok(tally)
}
fn run_item_build(
paths: &Paths,
installed: &InstalledItem,
dangerously_skip_build: bool,
) -> Result<()> {
let registry = Registry::load(paths)?;
let source = registry
.sources
.iter()
.find(|s| s.name == installed.source)
.ok_or_else(|| MindError::SourceNotFound {
name: installed.source.clone(),
})?;
let mut catalog_items = Vec::new();
crate::catalog::scan_source(paths, source, &mut catalog_items)?;
let catalog_item = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.ok_or_else(|| MindError::NotInstalled {
name: installed.name.clone(),
})?;
let commit = installed.commit.clone();
println!(
"rebuilding {} via transactional reinstall",
installed.display_key()
);
let hook_env = crate::hook::HookEnv {
item_profiles: installed.profiles.clone(),
..crate::commands::source_hook_env(source)
};
let new_installed = crate::install::install(
paths,
catalog_item,
&commit,
&catalog_items,
false,
dangerously_skip_build,
&hook_env,
)?;
let mut manifest = Manifest::load(paths)?;
manifest.insert(new_installed);
manifest.save(paths)?;
Ok(())
}
fn run_item_lifecycle_hooks(
paths: &Paths,
installed: &InstalledItem,
event: HookEventArg,
force: bool,
dangerously_skip: bool,
tally: &mut HookTally,
contributors: &mut Vec<String>,
) -> Result<()> {
let registry = Registry::load(paths)?;
let source = registry
.sources
.iter()
.find(|s| s.name == installed.source)
.ok_or_else(|| MindError::SourceNotFound {
name: installed.source.clone(),
})?;
let mut catalog_items: Vec<CatalogItem> = Vec::new();
crate::catalog::scan_source(paths, source, &mut catalog_items)?;
let catalog_item = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.ok_or_else(|| MindError::NotInstalled {
name: installed.name.clone(),
})?;
let store = paths.mind_home.join(&installed.store);
let commit = &installed.commit;
let hook_env = crate::hook::HookEnv {
item_profiles: installed.profiles.clone(),
..crate::commands::source_hook_env(source)
};
match event {
HookEventArg::Install | HookEventArg::Update => {
let event_name = event_label(event);
let all_hooks = if event == HookEventArg::Update {
catalog_item.update_hooks()
} else {
catalog_item.install_hooks()
};
if all_hooks.is_empty() {
crate::render::note(format!(
"note: no {event_name} hooks declared for {}",
installed.display_key()
));
return Ok(());
}
let current = Some(commit.as_str());
let hooks: Vec<&crate::mindfile::ResolvedHook> = all_hooks
.into_iter()
.filter(|h| force || !item_hook_already_ran(installed, &h.run, current))
.collect();
if hooks.is_empty() {
return Ok(());
}
tally.offered(hooks.len(), dangerously_skip);
contributors.push(item_hook_target(installed));
let (recorded, outcome) = crate::install::run_item_install_hooks_partial(
catalog_item,
&hooks,
&store,
commit,
dangerously_skip,
&hook_env,
);
record_item_hooks_run(paths, installed.key().as_str(), &recorded)?;
outcome?;
}
HookEventArg::Uninstall => {
let hooks = catalog_item.uninstall_hooks();
if hooks.is_empty() {
crate::render::note(format!(
"note: no uninstall hooks declared for {}",
installed.display_key()
));
return Ok(());
}
tally.offered(hooks.len(), dangerously_skip);
contributors.push(item_hook_target(installed));
crate::install::run_item_uninstall_hooks(
installed,
&hooks,
&store,
commit,
dangerously_skip,
&hook_env,
)?;
}
HookEventArg::Build => unreachable!("build handled by run_item_build"),
}
Ok(())
}
fn list_source_hooks(paths: &Paths, target: &str, selector: &str) -> Result<()> {
let registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let matching: Vec<&Source> = registry
.sources
.iter()
.filter(|s| source_matches_glob(&s.name, selector))
.collect();
if matching.is_empty() {
return Err(MindError::SourceNotFound {
name: selector.to_string(),
});
}
let mut sources_json: Vec<SourceHooksJson> = Vec::new();
for source in matching {
let clone_dir = source.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
println!("source: {}", strip_ansi(&source.name));
let current = source.commit.as_deref();
let mut hooks_json: Vec<SourceHookJson> = Vec::new();
if resolved.is_empty() {
println!(" (no source-level hooks declared)");
} else {
for h in &resolved {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
let status = recorded_event(h.event)
.map(|rec_event| install_hook_status(source, &h.run, rec_event, current));
println!(
" [{event_str}] {kind_str} {:?} {}",
h.run,
status.as_deref().unwrap_or("(not recorded)")
);
hooks_json.push(SourceHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
status,
});
}
}
let source_items: Vec<&InstalledItem> = manifest
.items
.values()
.filter(|it| it.source == source.name)
.collect();
let mut items_json: Vec<ItemHooksJson> = Vec::new();
if !source_items.is_empty() {
let mut catalog_items = Vec::new();
let _ = crate::catalog::scan_source(paths, source, &mut catalog_items);
for installed in source_items {
let item_hooks: Vec<_> = if let Some(c) = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
{
c.hooks.iter().collect()
} else {
vec![]
};
if !item_hooks.is_empty() {
println!(" item: {}", installed.display_key());
let mut item_hooks_json: Vec<ItemHookJson> = Vec::new();
for h in item_hooks {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
println!(" [{event_str}] {kind_str} {:?}", h.run);
item_hooks_json.push(ItemHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
});
}
items_json.push(ItemHooksJson {
item: installed.display_key(),
source: None,
hooks: item_hooks_json,
});
}
}
}
sources_json.push(SourceHooksJson {
source: strip_ansi(&source.name),
hooks: hooks_json,
items: items_json,
});
}
if crate::render::ctx().json {
emit_json(&HooksListResult {
schema: 1,
action: "hooks-list",
target: target.to_string(),
sources: sources_json,
items: Vec::new(),
})?;
}
Ok(())
}
fn list_item_hooks(paths: &Paths, target: &str, item_ref: &crate::resolve::ItemRef) -> Result<()> {
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, item_ref);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: item_ref.name.clone(),
});
}
let registry = Registry::load(paths)?;
let mut items_json: Vec<ItemHooksJson> = Vec::new();
for installed in matches {
println!(
"item: {} (source {})",
installed.display_key(),
strip_ansi(&installed.source)
);
let source = registry.sources.iter().find(|s| s.name == installed.source);
let hooks: Vec<ResolvedHook> = if let Some(src) = source {
let mut catalog_items = Vec::new();
let _ = crate::catalog::scan_source(paths, src, &mut catalog_items);
catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.map(|c| c.hooks.clone())
.unwrap_or_default()
} else {
vec![]
};
let mut hooks_json: Vec<ItemHookJson> = Vec::new();
if hooks.is_empty() {
println!(" (no hooks declared)");
} else {
for h in &hooks {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
println!(" [{event_str}] {kind_str} {:?}", h.run);
hooks_json.push(ItemHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
});
}
}
items_json.push(ItemHooksJson {
item: installed.display_key(),
source: Some(strip_ansi(&installed.source)),
hooks: hooks_json,
});
}
if crate::render::ctx().json {
emit_json(&HooksListResult {
schema: 1,
action: "hooks-list",
target: target.to_string(),
sources: Vec::new(),
items: items_json,
})?;
}
Ok(())
}
fn pin_description(pin: &Pin) -> String {
match pin {
Pin::DefaultBranch => "default branch".to_string(),
Pin::FollowBranch(b) => format!("branch {b}"),
Pin::Tag(t) => format!("tag {t}"),
Pin::Ref(r) => format!("ref {r}"),
}
}
fn item_hook_target(installed: &InstalledItem) -> String {
format!(
"{}#{}:{}",
strip_ansi(&installed.source),
installed.kind.as_str(),
strip_ansi(&installed.name)
)
}
fn hook_already_ran(
source: &Source,
command: &str,
event: RecordedEvent,
current: Option<&str>,
) -> bool {
current.is_some()
&& source
.install_hooks
.iter()
.any(|r| r.is(command, event) && !r.baseline && r.ran_at.as_deref() == current)
}
fn item_hook_already_ran(installed: &InstalledItem, command: &str, current: Option<&str>) -> bool {
current.is_some()
&& installed
.install_hooks
.iter()
.any(|r| r.command == command && r.ran_at.as_deref() == current)
}
fn record_item_hooks_run(paths: &Paths, key: &str, recorded: &[RecordedHook]) -> Result<()> {
if recorded.is_empty() {
return Ok(());
}
let mut manifest = Manifest::load(paths)?;
if let Some(item) = manifest.items.get_mut(key) {
for r in recorded {
if let Some(existing) = item
.install_hooks
.iter_mut()
.find(|e| e.command == r.command)
{
existing.ran_at = r.ran_at.clone();
} else {
item.install_hooks.push(r.clone());
}
}
manifest.save(paths)?;
}
Ok(())
}
fn record_hook_run(
source: &mut Source,
command: &str,
event: RecordedEvent,
ran_at: Option<String>,
) {
if let Some(r) = source
.install_hooks
.iter_mut()
.find(|r| r.is(command, event))
{
r.ran_at = ran_at;
r.baseline = false;
} else {
let mut rec = RecordedSourceHook::install(command, ran_at);
rec.event = Some(event);
source.install_hooks.push(rec);
}
}
fn source_skip_note(event_name: &str, label: &str, source_name: &str) -> String {
let label = strip_ansi(label);
let source_name = strip_ansi(source_name);
let quoted = crate::error::shell_quote(&source_name);
format!(
"note: skipped {event_name} hook '{label}' for {source_name} (not a terminal); \
re-run unattended with:\n mind hooks run {quoted} --event {event_name} \
--dangerously-skip-install-hook-check"
)
}
fn nothing_pending_note(
event_name: &str,
settled: &[String],
source_name: &str,
current: Option<&str>,
) -> String {
let safe_name = strip_ansi(source_name);
let quoted = crate::error::shell_quote(&safe_name);
let at = match current {
Some(c) => format!("already ran at {}", strip_ansi(c)),
None => "already ran".to_string(),
};
let which: Vec<String> = settled.iter().map(|l| format!("'{l}'")).collect();
format!(
"note: nothing pending for {safe_name}: {event_name} hook(s) {} {at}; \
use --force to run them anyway:\n mind hooks run {quoted} --event {event_name} --force",
which.join(", ")
)
}
fn recorded_event(event: HookEvent) -> Option<RecordedEvent> {
RecordedEvent::of(event)
}
fn event_label(event: HookEventArg) -> &'static str {
match event {
HookEventArg::Install => "install",
HookEventArg::Update => "update",
HookEventArg::Uninstall => "uninstall",
HookEventArg::Build => "build",
}
}
fn install_hook_status(
source: &Source,
command: &str,
event: RecordedEvent,
current: Option<&str>,
) -> String {
match source.install_hooks.iter().find(|r| r.is(command, event)) {
None => "pending (never ran)".to_string(),
Some(rec) => match &rec.ran_at {
None => "pending (never ran)".to_string(),
Some(ran) if rec.baseline => {
if current.is_some_and(|c| c == ran) {
format!("not pending (recorded at meld {ran})")
} else {
format!("pending (never ran; melded at {ran})")
}
}
Some(ran) => {
if current.is_some_and(|c| c == ran) {
format!("ran at {ran}")
} else {
format!("pending (last ran at {ran})")
}
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_could_consent_needs_work_no_runs_and_a_consent_failure() {
let t = |existed, ran, skipped_for_consent| {
HookTally {
existed,
ran,
skipped_for_consent,
}
.nothing_could_consent()
};
assert!(t(1, 0, 1));
assert!(t(5, 0, 5));
assert!(!t(0, 0, 0));
assert!(!t(0, 0, 1));
assert!(!t(2, 1, 1));
assert!(!t(1, 0, 0));
}
#[test]
fn offered_accumulates_hook_counts_across_items() {
let mut tally = HookTally::default();
tally.offered(3, true);
tally.offered(2, true);
assert_eq!(
tally,
HookTally {
existed: 5,
ran: 5,
skipped_for_consent: 0,
}
);
assert!(!tally.nothing_could_consent());
}
#[test]
fn offered_zero_hooks_is_a_no_op() {
let mut tally = HookTally::default();
tally.offered(0, true);
tally.offered(0, false);
assert_eq!(tally, HookTally::default());
assert!(!tally.nothing_could_consent());
}
#[test]
fn offered_under_the_bypass_predicts_ran_regardless_of_environment() {
let mut tally = HookTally::default();
tally.offered(4, true);
assert_eq!(tally.ran, 4);
assert_eq!(tally.skipped_for_consent, 0);
assert!(!tally.nothing_could_consent());
}
#[test]
fn event_label_matches_the_cli_event_values() {
assert_eq!(event_label(HookEventArg::Install), "install");
assert_eq!(event_label(HookEventArg::Uninstall), "uninstall");
assert_eq!(event_label(HookEventArg::Build), "build");
for arg in [
HookEventArg::Install,
HookEventArg::Uninstall,
HookEventArg::Build,
] {
let label = event_label(arg);
assert!(
!label.is_empty() && label.chars().all(|c| c.is_ascii_lowercase()),
"{label:?} must be a bare lowercase flag value"
);
}
}
#[test]
fn source_skip_note_shell_quotes_the_identity_in_the_runnable_command() {
let evil = "x'; touch /tmp/mind-skip-note-pwned; echo '";
let note = source_skip_note("install", "setup", evil);
assert!(
note.contains(&crate::error::shell_quote(evil)),
"the note's command must carry the shell-quoted identity: {note}"
);
assert!(
!note.contains("mind hooks run x'; touch"),
"the note must never splice the raw identity into the command: {note}"
);
}
#[test]
fn source_skip_note_command_is_inert_when_pasted_into_a_real_shell() {
use std::process::Command;
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel = std::path::Path::new("/tmp/mind-skip-note-rt-pwned");
let _ = std::fs::remove_file(sentinel);
let evil = "x; touch /tmp/mind-skip-note-rt-pwned; echo hi";
let note = source_skip_note("install", "setup", evil);
let lead = "re-run unattended with:\n";
let start = note.find(lead).expect("note has a runnable command") + lead.len();
let command = note[start..].lines().next().unwrap().trim();
assert!(
command.starts_with("mind hooks run "),
"extracted command must be the mind invocation: {command:?}"
);
let probe = command.replacen("mind hooks run", "printf '%s\\n'", 1);
let out = Command::new("sh")
.arg("-c")
.arg(&probe)
.output()
.expect("sh -c must run");
assert!(
!sentinel.exists(),
"the injected 'touch' must not have executed via the note's command: {probe:?}"
);
let _ = std::fs::remove_file(sentinel);
assert!(
String::from_utf8_lossy(&out.stdout).contains(evil),
"the shell must see the identity back literally, proving the quote \
was not interpreted: stdout {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn source_skip_note_whole_framed_remedy_is_inert_when_pasted_with_the_frame() {
use std::process::Command;
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel = std::path::Path::new("/tmp/mind-skip-note-whole-frame-pwned");
let _ = std::fs::remove_file(sentinel);
let evil = "$(touch /tmp/mind-skip-note-whole-frame-pwned)`touch /tmp/mind-skip-note-whole-frame-pwned`";
let note = source_skip_note("install", "setup", evil);
let lead_at = note
.find("re-run unattended with:")
.expect("note has a remedy frame");
let framed_remedy = ¬e[lead_at..];
let _ = Command::new("sh").arg("-c").arg(framed_remedy).output();
assert!(
!sentinel.exists(),
"pasting the whole framed remedy (frame included) must not execute \
the embedded command substitution: {framed_remedy:?}"
);
let _ = std::fs::remove_file(sentinel);
}
}