Skip to main content

tirith_core/
runner.rs

1/// Safe runner — Unix only.
2/// Downloads a script, analyzes it, optionally executes it with user confirmation.
3use std::fs;
4use std::io::{self, BufRead, Write};
5use std::process::Command;
6
7use sha2::{Digest, Sha256};
8
9use crate::receipt::Receipt;
10use crate::script_analysis;
11
12pub struct RunResult {
13    pub receipt: Receipt,
14    pub executed: bool,
15    pub exit_code: Option<i32>,
16}
17
18pub struct RunOptions {
19    pub url: String,
20    pub no_exec: bool,
21    pub interactive: bool,
22}
23
24pub fn run(opts: RunOptions) -> Result<RunResult, String> {
25    // Check TTY requirement
26    if !opts.no_exec && !opts.interactive {
27        return Err("tirith run requires an interactive terminal or --no-exec flag".to_string());
28    }
29
30    // Download with redirect chain collection
31    let mut redirects: Vec<String> = Vec::new();
32    let redirect_list = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
33    let redirect_list_clone = redirect_list.clone();
34
35    let client = reqwest::blocking::Client::builder()
36        .redirect(reqwest::redirect::Policy::custom(move |attempt| {
37            if let Ok(mut list) = redirect_list_clone.lock() {
38                list.push(attempt.url().to_string());
39            }
40            if attempt.previous().len() >= 10 {
41                attempt.stop()
42            } else {
43                attempt.follow()
44            }
45        }))
46        .build()
47        .map_err(|e| format!("http client: {e}"))?;
48
49    let response = client
50        .get(&opts.url)
51        .send()
52        .map_err(|e| format!("download failed: {e}"))?;
53
54    let final_url = response.url().to_string();
55    if let Ok(list) = redirect_list.lock() {
56        redirects = list.clone();
57    }
58
59    let content = response.bytes().map_err(|e| format!("read body: {e}"))?;
60
61    // Compute SHA256
62    let mut hasher = Sha256::new();
63    hasher.update(&content);
64    let sha256 = format!("{:x}", hasher.finalize());
65
66    // Cache
67    let cache_dir = crate::policy::data_dir()
68        .ok_or("cannot determine data directory")?
69        .join("cache");
70    fs::create_dir_all(&cache_dir).map_err(|e| format!("create cache: {e}"))?;
71    let cached_path = cache_dir.join(&sha256);
72    fs::write(&cached_path, &content).map_err(|e| format!("write cache: {e}"))?;
73
74    let content_str = String::from_utf8_lossy(&content);
75
76    // Analyze
77    let interpreter = script_analysis::detect_interpreter(&content_str);
78    let analysis = script_analysis::analyze(&content_str, interpreter);
79
80    // Detect git repo and branch
81    let (git_repo, git_branch) = detect_git_info();
82
83    // Create receipt
84    let receipt = Receipt {
85        url: opts.url.clone(),
86        final_url: Some(final_url),
87        redirects,
88        sha256: sha256.clone(),
89        size: content.len() as u64,
90        domains_referenced: analysis.domains_referenced,
91        paths_referenced: analysis.paths_referenced,
92        analysis_method: "static".to_string(),
93        privilege: if analysis.has_sudo {
94            "elevated".to_string()
95        } else {
96            "normal".to_string()
97        },
98        timestamp: chrono::Utc::now().to_rfc3339(),
99        cwd: std::env::current_dir()
100            .ok()
101            .map(|p| p.display().to_string()),
102        git_repo,
103        git_branch,
104    };
105
106    if opts.no_exec {
107        receipt.save().map_err(|e| format!("save receipt: {e}"))?;
108        return Ok(RunResult {
109            receipt,
110            executed: false,
111            exit_code: None,
112        });
113    }
114
115    // Show analysis summary
116    eprintln!(
117        "tirith: downloaded {} bytes (SHA256: {})",
118        content.len(),
119        &sha256[..12]
120    );
121    eprintln!("tirith: interpreter: {interpreter}");
122    if analysis.has_sudo {
123        eprintln!("tirith: WARNING: script uses sudo");
124    }
125    if analysis.has_eval {
126        eprintln!("tirith: WARNING: script uses eval");
127    }
128    if analysis.has_base64 {
129        eprintln!("tirith: WARNING: script uses base64");
130    }
131
132    // Confirm from /dev/tty
133    let tty = fs::OpenOptions::new()
134        .read(true)
135        .write(true)
136        .open("/dev/tty")
137        .map_err(|_| "cannot open /dev/tty for confirmation")?;
138
139    let mut tty_writer = io::BufWriter::new(&tty);
140    write!(tty_writer, "Execute this script? [y/N] ").map_err(|e| format!("tty write: {e}"))?;
141    tty_writer.flush().map_err(|e| format!("tty flush: {e}"))?;
142
143    let mut reader = io::BufReader::new(&tty);
144    let mut response_line = String::new();
145    reader
146        .read_line(&mut response_line)
147        .map_err(|e| format!("tty read: {e}"))?;
148
149    if !response_line.trim().eq_ignore_ascii_case("y") {
150        eprintln!("tirith: execution cancelled");
151        receipt.save().map_err(|e| format!("save receipt: {e}"))?;
152        return Ok(RunResult {
153            receipt,
154            executed: false,
155            exit_code: None,
156        });
157    }
158
159    // Execute
160    receipt.save().map_err(|e| format!("save receipt: {e}"))?;
161
162    let status = Command::new(interpreter)
163        .arg(&cached_path)
164        .status()
165        .map_err(|e| format!("execute: {e}"))?;
166
167    Ok(RunResult {
168        receipt,
169        executed: true,
170        exit_code: status.code(),
171    })
172}
173
174/// Detect git repo remote URL and current branch.
175fn detect_git_info() -> (Option<String>, Option<String>) {
176    let repo = Command::new("git")
177        .args(["remote", "get-url", "origin"])
178        .output()
179        .ok()
180        .filter(|o| o.status.success())
181        .and_then(|o| String::from_utf8(o.stdout).ok())
182        .map(|s| s.trim().to_string());
183
184    let branch = Command::new("git")
185        .args(["rev-parse", "--abbrev-ref", "HEAD"])
186        .output()
187        .ok()
188        .filter(|o| o.status.success())
189        .and_then(|o| String::from_utf8(o.stdout).ok())
190        .map(|s| s.trim().to_string());
191
192    (repo, branch)
193}