1use 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 if !opts.no_exec && !opts.interactive {
27 return Err("tirith run requires an interactive terminal or --no-exec flag".to_string());
28 }
29
30 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 let mut hasher = Sha256::new();
63 hasher.update(&content);
64 let sha256 = format!("{:x}", hasher.finalize());
65
66 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 let interpreter = script_analysis::detect_interpreter(&content_str);
78 let analysis = script_analysis::analyze(&content_str, interpreter);
79
80 let (git_repo, git_branch) = detect_git_info();
82
83 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 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 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 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
174fn 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}