#[cfg(unix)]
use tirith_core::runner::{self, RunOptions};
use std::process::Command;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::install_txn::{self, InstallPlan, OnlineMode, PackageManager, PlanRequest};
use tirith_core::policy::Policy;
use tirith_core::registry_api::{self, HttpRegistryClient};
use tirith_core::style::Stream;
use tirith_core::threatdb::{Ecosystem, ThreatDb};
use tirith_core::verdict::{Action, Verdict};
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum InstallSource {
Npm,
Pip,
Cargo,
Apt,
Brew,
Dnf,
Yum,
Pacman,
Scoop,
Docker,
Go,
Url,
}
impl InstallSource {
fn package_manager(self) -> Option<PackageManager> {
match self {
InstallSource::Npm => Some(PackageManager::Npm),
InstallSource::Pip => Some(PackageManager::Pip),
InstallSource::Cargo => Some(PackageManager::Cargo),
InstallSource::Apt => Some(PackageManager::Apt),
InstallSource::Brew => Some(PackageManager::Brew),
InstallSource::Dnf => Some(PackageManager::Dnf),
InstallSource::Yum => Some(PackageManager::Yum),
InstallSource::Pacman => Some(PackageManager::Pacman),
InstallSource::Scoop => Some(PackageManager::Scoop),
InstallSource::Docker => Some(PackageManager::Docker),
InstallSource::Go => Some(PackageManager::Go),
InstallSource::Url => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InstallRunOutput {
pub exit_code: Option<i32>,
pub stdout: Option<String>,
pub stderr: Option<String>,
}
pub trait InstallRunner {
fn run(
&self,
program: &str,
args: &[String],
capture: bool,
) -> std::io::Result<InstallRunOutput>;
}
pub struct ProcessInstallRunner;
impl InstallRunner for ProcessInstallRunner {
fn run(
&self,
program: &str,
args: &[String],
capture: bool,
) -> std::io::Result<InstallRunOutput> {
if capture {
let output = Command::new(program).args(args).output()?;
Ok(InstallRunOutput {
exit_code: output.status.code(),
stdout: Some(String::from_utf8_lossy(&output.stdout).into_owned()),
stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()),
})
} else {
let status = Command::new(program).args(args).status()?;
Ok(InstallRunOutput {
exit_code: status.code(),
stdout: None,
stderr: None,
})
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
source: InstallSource,
args: &[String],
online: bool,
offline: bool,
json: bool,
yes: bool,
no_exec: bool,
sha256: Option<String>,
) -> i32 {
const MISPLACED_TIRITH_FLAGS: &[&str] = &["--no-exec", "--online", "--yes"];
if let Some(flag) = args
.iter()
.find(|a| MISPLACED_TIRITH_FLAGS.contains(&a.as_str()))
{
eprintln!(
"tirith install: `{flag}` is a tirith option and must come before \
the <source> argument (e.g. `tirith install {flag} npm \
<package>`). After <source>, arguments go to the package manager \
— a misplaced `{flag}` would not affect tirith."
);
return 2;
}
match source.package_manager() {
Some(manager) => run_package_manager(manager, args, online, offline, json, yes, no_exec),
None => run_url(args, online, offline, json, no_exec, sha256),
}
}
#[allow(clippy::too_many_arguments)]
fn run_package_manager(
manager: PackageManager,
args: &[String],
online: bool,
offline: bool,
json: bool,
yes: bool,
no_exec: bool,
) -> i32 {
if args.is_empty() {
eprintln!(
"tirith install: no packages or arguments given for {}.",
manager.label()
);
eprintln!(
" try: tirith install {} <package> (e.g. tirith install {} {})",
manager.label(),
manager.label(),
example_package(manager),
);
return 2;
}
let interactive = is_terminal::is_terminal(std::io::stderr());
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let policy = Policy::discover(cwd.as_deref());
let use_online = online && !offline && !super::offline_env_active();
let http_client = HttpRegistryClient::new();
let resolver = |eco: Ecosystem, name: &str| {
let (mut signals, existence) = registry_api::gather_api_signals(&http_client, eco, name);
use tirith_core::package_risk::{ApiProvenance, PackageExistence};
match &mut signals {
tirith_core::package_risk::ApiSignals::Available { provenance } => {
provenance.package_existence = existence;
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
provenance.dep_confusion = Some(dc);
}
}
tirith_core::package_risk::ApiSignals::Unavailable { .. }
if matches!(existence, PackageExistence::NotFound) =>
{
let mut prov = ApiProvenance {
source: eco.to_string(),
package_existence: PackageExistence::NotFound,
..Default::default()
};
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
prov.dep_confusion = Some(dc);
}
signals = tirith_core::package_risk::ApiSignals::Available { provenance: prov };
}
_ => {}
}
signals
};
let online_mode = if use_online {
OnlineMode::Resolver(&resolver)
} else {
OnlineMode::Off
};
let db = ThreatDb::cached();
let plan_request = PlanRequest {
manager,
user_args: args,
db: db.as_deref(),
policy: &policy,
cwd: cwd.clone(),
interactive,
online: online_mode,
};
let mut plan = install_txn::plan_install(&plan_request);
plan.verdict.agent_origin = Some(tirith_core::agent_origin::resolve_cli_origin(interactive));
if !plan.verdict.bypass_honored {
tirith_core::escalation::apply_agent_rules(&mut plan.verdict, &policy);
}
if !json {
print_plan_human(&plan, use_online);
}
let decision = if no_exec {
if !json {
eprintln!(
"tirith install: --no-exec — analysis only, '{}' was NOT run.",
plan.analysis_command
);
}
match plan.verdict.action {
Action::Allow => ProceedDecision::Stop(0),
Action::Block => ProceedDecision::Stop(1),
Action::Warn | Action::WarnAck => ProceedDecision::Stop(2),
}
} else {
decide_proceed(&plan.verdict, &policy, interactive, yes, json)
};
if matches!(decision, ProceedDecision::Go) && plan.verdict.action == Action::Block {
plan.verdict.bypass_requested = true;
plan.verdict.bypass_honored = true;
}
if let Err(e) = tirith_core::audit::log_verdict(
&plan.verdict,
&format!("install {}", plan.analysis_command),
None,
None,
&policy.dlp_custom_patterns,
) {
if !json {
eprintln!("tirith install: audit log not written (non-fatal): {e}");
}
}
match decision {
ProceedDecision::Stop(code) => {
if json && !emit_combined_json(&plan, use_online, None) {
return 1;
}
code
}
ProceedDecision::Go => {
if plan.manager.is_windows_only_runtime() && !cfg!(target_os = "windows") {
if !json {
eprintln!(
"tirith install: refusing to run '{}' — {} is Windows-only \
at the real-run step. Use `--no-exec` to analyze on this OS, \
or run the command from a Windows host.",
plan.analysis_command,
plan.manager.label(),
);
} else {
let _ = emit_combined_json(
&plan,
use_online,
Some(OutcomeRecord {
ran: false,
exit_code: None,
checkpoint_id: None,
stdout: None,
stderr: None,
spawn_error: Some(format!(
"{} is Windows-only at the real-run step",
plan.manager.label()
)),
}),
);
}
return 2;
}
run_and_record(
&plan,
cwd.as_deref(),
json,
use_online,
&ProcessInstallRunner,
)
}
}
}
enum ProceedDecision {
Go,
Stop(i32),
}
fn decide_proceed(
verdict: &Verdict,
policy: &Policy,
interactive: bool,
yes: bool,
json: bool,
) -> ProceedDecision {
match verdict.action {
Action::Allow => ProceedDecision::Go,
Action::Block => {
let bypass_set = std::env::var("TIRITH").ok().as_deref() == Some("0");
let bypass_allowed =
policy.allow_bypass_env && (interactive || policy.allow_bypass_env_noninteractive);
if bypass_set && bypass_allowed {
if !json {
eprintln!(
"tirith install: BLOCK bypassed via TIRITH=0 (policy permits) — \
proceeding against advice."
);
}
ProceedDecision::Go
} else {
if !json {
eprintln!(
"tirith install: refusing to install — the analysis BLOCKED \
this transaction (see findings above)."
);
if policy.allow_bypass_env {
eprintln!(" to override against advice: TIRITH=0 tirith install ...");
}
}
ProceedDecision::Stop(1)
}
}
Action::Warn | Action::WarnAck => {
if yes {
if !json {
eprintln!(
"tirith install: proceeding past {} warning(s) (--yes).",
verdict.findings.len()
);
}
return ProceedDecision::Go;
}
if !interactive {
if !json {
eprintln!(
"tirith install: {} warning(s) — not installing in a \
non-interactive session. Re-run with --yes to proceed.",
verdict.findings.len()
);
}
return ProceedDecision::Stop(2);
}
eprint!(
"tirith install: proceed with {} warning(s) and install? [y/N] ",
verdict.findings.len()
);
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
eprintln!("tirith install: could not read confirmation — not installing.");
return ProceedDecision::Stop(2);
}
if matches!(input.trim(), "y" | "Y" | "yes" | "Yes") {
ProceedDecision::Go
} else {
eprintln!("tirith install: cancelled — nothing was installed.");
ProceedDecision::Stop(2)
}
}
}
}
fn run_and_record(
plan: &InstallPlan,
cwd: Option<&str>,
json: bool,
online: bool,
runner: &dyn InstallRunner,
) -> i32 {
let checkpoint_id = match cwd {
Some(dir) => {
let trigger = format!("install {}", plan.analysis_command);
match tirith_core::checkpoint::create(&[dir], Some(&trigger)) {
Ok(meta) => {
if !json {
eprintln!(
"tirith install: checkpoint {} taken ({} file(s)) — \
before/after record only, not a sandbox.",
meta.id, meta.file_count
);
}
Some(meta.id)
}
Err(e) => {
if !json {
eprintln!("tirith install: checkpoint skipped (non-fatal): {e}");
}
None
}
}
}
None => None,
};
if !json {
eprintln!("tirith install: running '{}' ...", plan.analysis_command);
}
let run_output = match runner.run(
&plan.argv.program,
&plan.argv.args,
json,
) {
Ok(out) => out,
Err(e) => {
if !json {
eprintln!("tirith install: failed to run '{}': {e}", plan.argv.program);
} else {
let _ = emit_combined_json(
plan,
online,
Some(OutcomeRecord {
ran: false,
exit_code: None,
checkpoint_id: checkpoint_id.as_deref(),
stdout: None,
stderr: None,
spawn_error: Some(e.to_string()),
}),
);
}
return 1;
}
};
let exit_code = match run_output.exit_code {
Some(code) => code,
None => {
if !json {
eprintln!(
"tirith install: '{}' did not return an exit code \
(terminated by signal).",
plan.argv.program
);
}
1
}
};
if json {
let outcome = OutcomeRecord {
ran: true,
exit_code: Some(exit_code),
checkpoint_id: checkpoint_id.as_deref(),
stdout: run_output.stdout.as_deref(),
stderr: run_output.stderr.as_deref(),
spawn_error: None,
};
if !emit_combined_json(plan, online, Some(outcome)) && exit_code == 0 {
return 1;
}
} else {
let after = if exit_code == 0 {
"completed".to_string()
} else {
format!("exited {exit_code}")
};
eprintln!("tirith install: '{}' {after}.", plan.analysis_command);
if let Some(id) = &checkpoint_id {
eprintln!(" before/after record: tirith checkpoint diff {id}");
}
}
exit_code
}
struct OutcomeRecord<'a> {
ran: bool,
exit_code: Option<i32>,
checkpoint_id: Option<&'a str>,
stdout: Option<&'a str>,
stderr: Option<&'a str>,
spawn_error: Option<String>,
}
fn emit_combined_json(plan: &InstallPlan, online: bool, outcome: Option<OutcomeRecord>) -> bool {
#[derive(serde::Serialize)]
struct PackageOut<'a> {
ecosystem: String,
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
risk_score: u32,
risk_level: &'a str,
}
#[derive(serde::Serialize)]
struct AnalysisEnvelope<'a> {
kind: &'a str,
manager: &'a str,
command: &'a str,
sandboxed: bool,
online: bool,
packages: Vec<PackageOut<'a>>,
verdict: &'a Verdict,
notes: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
signals_note: Option<&'a str>,
}
#[derive(serde::Serialize)]
struct OutcomeEnvelope<'a> {
kind: &'a str,
manager: &'a str,
command: &'a str,
sandboxed: bool,
ran: bool,
#[serde(skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
checkpoint_id: Option<&'a str>,
verdict_action: String,
#[serde(skip_serializing_if = "Option::is_none")]
stdout: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
stderr: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
spawn_error: Option<&'a str>,
}
#[derive(serde::Serialize)]
struct CombinedEnvelope<'a> {
schema_version: u32,
kind: &'a str,
analysis: AnalysisEnvelope<'a>,
outcome: Option<OutcomeEnvelope<'a>>,
}
let packages = plan
.packages
.iter()
.map(|p| PackageOut {
ecosystem: p.reference.ecosystem.to_string(),
name: &p.reference.name,
version: p.reference.version.as_deref(),
risk_score: p.risk.score,
risk_level: p.risk.risk_level,
})
.collect();
let signals_banner_owned: Option<String> = if plan.manager.lacks_registry_adapter() {
Some(plan.manager.no_registry_adapter_banner())
} else {
None
};
let analysis = AnalysisEnvelope {
kind: "install_analysis",
manager: plan.manager.label(),
command: &plan.analysis_command,
sandboxed: false,
online,
packages,
verdict: &plan.verdict,
notes: &plan.notes,
signals_note: signals_banner_owned.as_deref(),
};
let spawn_error_owned: Option<String> = outcome.as_ref().and_then(|o| o.spawn_error.clone());
let outcome_env = outcome.map(|o| OutcomeEnvelope {
kind: "install_outcome",
manager: plan.manager.label(),
command: &plan.analysis_command,
sandboxed: false,
ran: o.ran,
exit_code: o.exit_code,
checkpoint_id: o.checkpoint_id,
verdict_action: format!("{:?}", plan.verdict.action),
stdout: o.stdout,
stderr: o.stderr,
spawn_error: spawn_error_owned.as_deref(),
});
let combined = CombinedEnvelope {
schema_version: 2,
kind: "install",
analysis,
outcome: outcome_env,
};
write_json_stdout(&combined)
}
#[cfg(unix)]
fn run_url(
args: &[String],
_online: bool,
_offline: bool,
json: bool,
no_exec: bool,
sha256: Option<String>,
) -> i32 {
let url = match args {
[single] => single.as_str(),
[] => {
eprintln!("tirith install: no URL given.");
eprintln!(" try: tirith install url https://get.example-tool.sh");
return 2;
}
_ => {
eprintln!(
"tirith install: the url form takes exactly one URL \
(got {} arguments).",
args.len()
);
return 2;
}
};
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let interactive = is_terminal::is_terminal(std::io::stderr());
let (mut preflight, policy) = preflight_url(url, cwd.as_deref(), interactive);
preflight.agent_origin = Some(tirith_core::agent_origin::resolve_cli_origin(interactive));
if !preflight.bypass_honored {
tirith_core::escalation::apply_agent_rules(&mut preflight, &policy);
}
if json {
if !print_url_preflight_json(url, &preflight) {
return 1;
}
} else {
print_url_preflight_human(url, &preflight);
}
let blocked_and_refused = if preflight.action == Action::Block {
let bypass_set = std::env::var("TIRITH").ok().as_deref() == Some("0");
let bypass_allowed =
policy.allow_bypass_env && (interactive || policy.allow_bypass_env_noninteractive);
if bypass_set && bypass_allowed {
preflight.bypass_requested = true;
preflight.bypass_honored = true;
false
} else {
true
}
} else {
false
};
if let Err(e) = tirith_core::audit::log_verdict(
&preflight,
&format!("install url {url}"),
None,
None,
&policy.dlp_custom_patterns,
) {
if !json {
eprintln!("tirith install: audit log not written (non-fatal): {e}");
}
}
if blocked_and_refused {
if !json {
eprintln!(
"tirith install: refusing to download — the URL preflight \
BLOCKED this transaction (see findings above)."
);
}
return 1;
}
if preflight.action == Action::Block && !json {
eprintln!(
"tirith install: URL preflight BLOCK bypassed via TIRITH=0 \
(policy permits) — proceeding against advice."
);
}
if preflight.action != Action::Allow && !json {
eprintln!(
"tirith install: URL preflight raised {} finding(s) — the script \
body will be analyzed and you will be asked to confirm before it runs.",
preflight.findings.len()
);
}
let opts = RunOptions {
url: url.to_string(),
no_exec,
interactive,
expected_sha256: sha256,
};
match runner::run(opts) {
Ok(result) => {
let json_ok = if json {
print_url_outcome_json(&result)
} else {
if result.executed {
eprintln!(
"tirith install: install script executed (receipt {}).",
tirith_core::receipt::short_hash(&result.receipt.sha256)
);
} else {
eprintln!(
"tirith install: install script downloaded and recorded, \
not executed (receipt {}).",
tirith_core::receipt::short_hash(&result.receipt.sha256)
);
}
true
};
let outcome_code = if result.executed {
result.exit_code.unwrap_or(1)
} else {
0
};
if !json_ok && outcome_code == 0 {
1
} else {
outcome_code
}
}
Err(e) => {
if json {
let _ = write_json_stdout(&serde_json::json!({ "error": e }));
} else {
eprintln!("tirith install: {e}");
}
1
}
}
}
#[cfg(not(unix))]
fn run_url(
_args: &[String],
_online: bool,
_offline: bool,
_json: bool,
_no_exec: bool,
_sha256: Option<String>,
) -> i32 {
eprintln!(
"tirith install: the url form is only available on Unix. \
Use `tirith install npm|pip|cargo` instead."
);
2
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn preflight_url(url: &str, cwd: Option<&str>, interactive: bool) -> (Verdict, Policy) {
let ctx = AnalysisContext {
input: format!("curl -fsSL {}", shell_single_quote(url)),
shell: tirith_core::tokenize::ShellType::Posix,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive,
cwd: cwd.map(|s| s.to_string()),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
card_ref: None,
clipboard_source: tirith_core::clipboard::ClipboardSourceState::Unread,
};
engine::analyze_returning_policy(&ctx)
}
fn example_package(manager: PackageManager) -> &'static str {
match manager {
PackageManager::Npm => "left-pad",
PackageManager::Pip => "requests",
PackageManager::Cargo => "ripgrep",
PackageManager::Apt => "nginx",
PackageManager::Brew => "ripgrep",
PackageManager::Dnf => "httpd",
PackageManager::Yum => "httpd",
PackageManager::Pacman => "firefox",
PackageManager::Scoop => "neovim",
PackageManager::Docker => "alpine:latest",
PackageManager::Go => "github.com/spf13/cobra@latest",
}
}
fn print_plan_human(plan: &InstallPlan, online: bool) {
let s = Stream::Stderr;
eprintln!(
"tirith install: analyzing '{}' before running it",
plan.analysis_command
);
eprintln!(" (pre-execution install-risk analysis — not a sandbox)");
if plan.manager.lacks_registry_adapter() {
eprintln!(" {}", plan.manager.no_registry_adapter_banner());
}
eprintln!();
if plan.packages.is_empty() {
eprintln!(" packages: none on the command line (command-shape analysis only)");
} else {
eprintln!(" packages:");
for pkg in &plan.packages {
eprintln!(
" - {} {} — risk {}/100 ({})",
pkg.reference.ecosystem, pkg.reference.name, pkg.risk.score, pkg.risk.risk_level,
);
}
}
eprintln!();
match plan.verdict.action {
Action::Allow => {
eprintln!(
" {}",
tirith_core::style::green("verdict: ALLOW — no supply-chain risks found", s)
);
}
Action::Warn | Action::WarnAck => {
eprintln!(
" {}",
tirith_core::style::yellow(
&format!(
"verdict: WARN — {} finding(s), acknowledgement required",
plan.verdict.findings.len()
),
s,
)
);
}
Action::Block => {
eprintln!(
" {}",
tirith_core::style::bold_red(
&format!(
"verdict: BLOCK — {} finding(s), install refused",
plan.verdict.findings.len()
),
s,
)
);
}
}
for finding in &plan.verdict.findings {
let sev = tirith_core::style::severity_label(&finding.severity, s);
eprintln!(" {} {} — {}", sev, finding.rule_id, finding.title);
eprintln!(" {}", finding.description);
}
if !plan.notes.is_empty() {
eprintln!();
eprintln!(" notes:");
for note in &plan.notes {
eprintln!(" - {note}");
}
}
if !online && !plan.packages.is_empty() {
eprintln!();
eprintln!(
" (offline analysis — re-run with --online to add registry-API \
provenance signals)"
);
}
eprintln!();
}
fn write_json_stdout<T: serde::Serialize>(value: &T) -> bool {
super::write_json_stdout(value, "tirith install: failed to write JSON output")
}
fn print_url_preflight_human(url: &str, verdict: &Verdict) {
let s = Stream::Stderr;
eprintln!("tirith install: preflight analysis of install URL");
eprintln!(" url: {url}");
eprintln!(" (pre-execution URL-risk analysis — the script body is analyzed after download; not a sandbox)");
match verdict.action {
Action::Allow => {
eprintln!(" {}", tirith_core::style::green("preflight: ALLOW", s));
}
Action::Warn | Action::WarnAck => {
eprintln!(
" {}",
tirith_core::style::yellow(
&format!("preflight: WARN — {} finding(s)", verdict.findings.len()),
s,
)
);
}
Action::Block => {
eprintln!(
" {}",
tirith_core::style::bold_red(
&format!("preflight: BLOCK — {} finding(s)", verdict.findings.len()),
s,
)
);
}
}
for finding in &verdict.findings {
let sev = tirith_core::style::severity_label(&finding.severity, s);
eprintln!(" {} {} — {}", sev, finding.rule_id, finding.title);
eprintln!(" {}", finding.description);
}
}
fn print_url_preflight_json(url: &str, verdict: &Verdict) -> bool {
let out = serde_json::json!({
"schema_version": 1,
"kind": "install_url_preflight",
"url": url,
"sandboxed": false,
"verdict": verdict,
});
write_json_stdout(&out)
}
#[cfg(unix)]
fn print_url_outcome_json(result: &runner::RunResult) -> bool {
let out = serde_json::json!({
"schema_version": 1,
"kind": "install_url_outcome",
"sandboxed": false,
"receipt": &result.receipt,
"executed": result.executed,
"exit_code": result.exit_code,
});
write_json_stdout(&out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tirith_core::verdict::{Finding, RuleId, Severity};
struct FakeRunner {
exit_code: Option<i32>,
stdout: Option<String>,
stderr: Option<String>,
seen: Mutex<Vec<(String, Vec<String>, bool)>>,
}
impl FakeRunner {
fn new(exit_code: Option<i32>) -> Self {
Self {
exit_code,
stdout: None,
stderr: None,
seen: Mutex::new(Vec::new()),
}
}
fn with_capture(mut self, stdout: &str, stderr: &str) -> Self {
self.stdout = Some(stdout.to_string());
self.stderr = Some(stderr.to_string());
self
}
}
impl InstallRunner for FakeRunner {
fn run(
&self,
program: &str,
args: &[String],
capture: bool,
) -> std::io::Result<InstallRunOutput> {
self.seen
.lock()
.unwrap()
.push((program.to_string(), args.to_vec(), capture));
Ok(InstallRunOutput {
exit_code: self.exit_code,
stdout: if capture { self.stdout.clone() } else { None },
stderr: if capture { self.stderr.clone() } else { None },
})
}
}
fn allow_verdict() -> Verdict {
Verdict::from_findings(vec![], 3, Default::default())
}
fn warn_verdict() -> Verdict {
Verdict::from_findings(
vec![Finding {
rule_id: RuleId::ThreatSuspiciousPackage,
severity: Severity::Medium,
title: "t".to_string(),
description: "d".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}],
3,
Default::default(),
)
}
fn block_verdict() -> Verdict {
Verdict::from_findings(
vec![Finding {
rule_id: RuleId::ThreatMaliciousPackage,
severity: Severity::Critical,
title: "t".to_string(),
description: "d".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}],
3,
Default::default(),
)
}
#[test]
fn install_source_package_manager_mapping() {
assert_eq!(
InstallSource::Npm.package_manager(),
Some(PackageManager::Npm)
);
assert_eq!(
InstallSource::Pip.package_manager(),
Some(PackageManager::Pip)
);
assert_eq!(
InstallSource::Cargo.package_manager(),
Some(PackageManager::Cargo)
);
assert_eq!(
InstallSource::Apt.package_manager(),
Some(PackageManager::Apt)
);
assert_eq!(
InstallSource::Brew.package_manager(),
Some(PackageManager::Brew)
);
assert_eq!(
InstallSource::Dnf.package_manager(),
Some(PackageManager::Dnf)
);
assert_eq!(
InstallSource::Yum.package_manager(),
Some(PackageManager::Yum)
);
assert_eq!(
InstallSource::Pacman.package_manager(),
Some(PackageManager::Pacman)
);
assert_eq!(
InstallSource::Scoop.package_manager(),
Some(PackageManager::Scoop)
);
assert_eq!(
InstallSource::Docker.package_manager(),
Some(PackageManager::Docker)
);
assert_eq!(
InstallSource::Go.package_manager(),
Some(PackageManager::Go)
);
assert_eq!(InstallSource::Url.package_manager(), None);
}
#[test]
fn decide_proceed_allow_goes() {
let policy = Policy::default();
assert!(matches!(
decide_proceed(&allow_verdict(), &policy, false, false, true),
ProceedDecision::Go
));
}
#[test]
fn decide_proceed_block_stops_with_exit_1() {
let policy = Policy::default();
assert!(matches!(
decide_proceed(&block_verdict(), &policy, true, false, true),
ProceedDecision::Stop(1)
));
}
#[test]
fn decide_proceed_warn_noninteractive_stops_without_yes() {
let policy = Policy::default();
assert!(matches!(
decide_proceed(&warn_verdict(), &policy, false, false, true),
ProceedDecision::Stop(2)
));
}
#[test]
fn decide_proceed_warn_with_yes_goes() {
let policy = Policy::default();
assert!(matches!(
decide_proceed(&warn_verdict(), &policy, false, true, true),
ProceedDecision::Go
));
}
#[test]
fn fake_runner_records_argv_and_never_spawns() {
let req_args = vec!["my-pkg".to_string()];
let argv = install_txn::build_argv(PackageManager::Npm, &req_args);
let plan = InstallPlan {
manager: PackageManager::Npm,
argv: argv.clone(),
analysis_command: argv.display(),
packages: vec![],
verdict: allow_verdict(),
notes: vec![],
};
let runner = FakeRunner::new(Some(0));
let code = run_and_record(&plan, None, true, false, &runner);
assert_eq!(code, 0);
let seen = runner.seen.lock().unwrap();
assert_eq!(seen.len(), 1, "the runner must be called exactly once");
assert_eq!(seen[0].0, "npm");
assert_eq!(seen[0].1, vec!["install", "my-pkg"]);
assert!(
seen[0].2,
"JSON mode must request capture so child output is embedded"
);
}
#[test]
fn fake_runner_propagates_nonzero_exit() {
let req_args = vec!["my-pkg".to_string()];
let argv = install_txn::build_argv(PackageManager::Cargo, &req_args);
let plan = InstallPlan {
manager: PackageManager::Cargo,
argv: argv.clone(),
analysis_command: argv.display(),
packages: vec![],
verdict: allow_verdict(),
notes: vec![],
};
let runner = FakeRunner::new(Some(17));
let code = run_and_record(&plan, None, true, false, &runner);
assert_eq!(code, 17, "the install's own exit code must propagate");
}
#[test]
fn fake_runner_signal_termination_is_failure() {
let argv = install_txn::build_argv(PackageManager::Pip, &["x".to_string()]);
let plan = InstallPlan {
manager: PackageManager::Pip,
argv: argv.clone(),
analysis_command: argv.display(),
packages: vec![],
verdict: allow_verdict(),
notes: vec![],
};
let runner = FakeRunner::new(None); let code = run_and_record(&plan, None, true, false, &runner);
assert_eq!(code, 1);
}
#[test]
fn json_mode_emits_single_parseable_envelope() {
let req_args = vec!["clean-pkg".to_string()];
let argv = install_txn::build_argv(PackageManager::Pip, &req_args);
let plan = InstallPlan {
manager: PackageManager::Pip,
argv: argv.clone(),
analysis_command: argv.display(),
packages: vec![],
verdict: allow_verdict(),
notes: vec![],
};
let runner = FakeRunner::new(Some(0))
.with_capture("installing clean-pkg\nDone.\n", "warning: deprecated\n");
let _code = run_and_record(&plan, None, true, false, &runner);
let seen = runner.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert!(
seen[0].2,
"JSON mode must request capture; saw capture={}",
seen[0].2,
);
}
#[test]
fn detect_manifest_flag_helper_is_internal() {
let req = tirith_core::install_txn::PlanRequest {
manager: PackageManager::Pip,
user_args: &["-r".to_string(), "requirements.txt".to_string()],
db: None,
policy: &Policy::default(),
cwd: None,
interactive: false,
online: tirith_core::install_txn::OnlineMode::Off,
};
let plan = tirith_core::install_txn::plan_install(&req);
assert!(
plan.verdict
.findings
.iter()
.any(|f| f.title.contains("manifest install")),
"the manifest-bypass finding must surface from the CLI's plan_install \
call too: {:?}",
plan.verdict.findings,
);
}
#[test]
fn preflight_url_plain_installer_does_not_block() {
let (verdict, _policy) =
preflight_url("https://get.example-tool.sh/install.sh", None, false);
assert_ne!(
verdict.action,
Action::Block,
"a download-shaped preflight must not block a plain installer URL: {:?}",
verdict.findings,
);
}
#[test]
fn preflight_url_raw_ip_is_flagged() {
let (verdict, _policy) = preflight_url("http://203.0.113.5/install.sh", None, false);
assert!(
verdict.action != Action::Allow,
"a raw-IP install URL should raise at least one finding"
);
}
#[test]
fn shell_single_quote_escapes_metacharacters() {
assert_eq!(
shell_single_quote("https://x.example/a?b=1&c=2"),
"'https://x.example/a?b=1&c=2'"
);
assert_eq!(shell_single_quote("a'b"), "'a'\\''b'");
}
#[test]
fn preflight_url_with_shell_metacharacters_no_spurious_findings() {
let url = "https://get.example-tool.sh/install.sh?ref=a&v=1;x y";
let (verdict, _policy) = preflight_url(url, None, false);
assert_eq!(
verdict.action,
Action::Allow,
"a quoted benign installer URL must not raise shell-syntax findings: {:?}",
verdict.findings,
);
}
#[test]
fn preflight_url_quoting_preserves_real_detection() {
let (verdict, _policy) =
preflight_url("http://203.0.113.5/install.sh?a=1&b=2", None, false);
assert!(
verdict.action != Action::Allow,
"quoting must not suppress a raw-IP finding"
);
}
}