1use std::collections::{BTreeMap, BTreeSet};
17use std::sync::OnceLock;
18
19use sha2::{Digest, Sha256};
20
21use crate::cst::{self, Cmd, Script, SimpleCmd, Word, WordPart};
22use crate::registry;
23
24const DEFAULT_LEVEL: &str = "SafeWrite";
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GeneratedEntry {
32 pub name: String,
33 pub standalone: Vec<String>,
35 pub max_positional: usize,
37 pub level: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Outcome {
43 AlreadyAllowed,
45 Unparseable,
47 RecognizedButDenied { names: Vec<String> },
50 Generated {
53 entries: Vec<GeneratedEntry>,
54 also_recognized: Vec<String>,
55 },
56}
57
58pub fn analyze(command: &str) -> Outcome {
60 if cst::command_verdict(command).is_allowed() {
61 return Outcome::AlreadyAllowed;
62 }
63 let Some(script) = cst::parse(command) else {
64 return Outcome::Unparseable;
65 };
66
67 let mut simples: Vec<&SimpleCmd> = Vec::new();
68 collect_script(&script, &mut simples);
69
70 let mut unknown: BTreeMap<String, (BTreeSet<String>, usize)> = BTreeMap::new();
72 let mut recognized: BTreeSet<String> = BTreeSet::new();
73 for sc in simples {
74 let Some(name) = command_basename(sc) else {
75 continue;
76 };
77 if is_known(&name) {
78 recognized.insert(name);
79 continue;
80 }
81 let (flags, positionals) = observed_shape(sc);
82 let entry = unknown.entry(name).or_default();
83 entry.0.extend(flags);
84 entry.1 = entry.1.max(positionals);
85 }
86
87 if unknown.is_empty() {
88 return Outcome::RecognizedButDenied {
89 names: recognized.into_iter().collect(),
90 };
91 }
92 let entries = unknown
93 .into_iter()
94 .map(|(name, (flags, max_positional))| GeneratedEntry {
95 name,
96 standalone: flags.into_iter().collect(),
97 max_positional,
98 level: DEFAULT_LEVEL.to_string(),
99 })
100 .collect();
101 Outcome::Generated {
102 entries,
103 also_recognized: recognized.into_iter().collect(),
104 }
105}
106
107fn command_basename(sc: &SimpleCmd) -> Option<String> {
110 let raw = sc.words.first()?.eval();
111 if raw.is_empty() {
112 return None;
113 }
114 Some(crate::parse::Token::from_raw(raw).command_name().to_string())
115}
116
117fn observed_shape(sc: &SimpleCmd) -> (Vec<String>, usize) {
121 let mut flags = Vec::new();
122 let mut positionals = 0;
123 for word in sc.words.iter().skip(1) {
124 let s = word.eval();
125 if s.starts_with('-') && s != "-" {
126 flags.push(s);
127 } else {
128 positionals += 1;
129 }
130 }
131 (flags, positionals)
132}
133
134fn collect_script<'a>(script: &'a Script, out: &mut Vec<&'a SimpleCmd>) {
135 for stmt in &script.0 {
136 for cmd in &stmt.pipeline.commands {
137 collect_cmd(cmd, out);
138 }
139 }
140}
141
142fn collect_cmd<'a>(cmd: &'a Cmd, out: &mut Vec<&'a SimpleCmd>) {
143 match cmd {
144 Cmd::Simple(sc) => {
145 out.push(sc);
146 for word in &sc.words {
147 collect_word(word, out);
148 }
149 }
150 Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } => collect_script(body, out),
151 Cmd::For { items, body, .. } => {
152 for word in items {
153 collect_word(word, out);
154 }
155 collect_script(body, out);
156 }
157 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
158 collect_script(cond, out);
159 collect_script(body, out);
160 }
161 Cmd::If {
162 branches,
163 else_body,
164 ..
165 } => {
166 for branch in branches {
167 collect_script(&branch.cond, out);
168 collect_script(&branch.body, out);
169 }
170 if let Some(body) = else_body {
171 collect_script(body, out);
172 }
173 }
174 Cmd::DoubleBracket { words, .. } => {
175 for word in words {
176 collect_word(word, out);
177 }
178 }
179 Cmd::Case { subject, arms, .. } => {
180 collect_word(subject, out);
181 for arm in arms {
182 collect_script(&arm.body, out);
183 }
184 }
185 Cmd::FunctionDef { body, .. } => collect_script(body, out),
186 }
187}
188
189fn collect_word<'a>(word: &'a Word, out: &mut Vec<&'a SimpleCmd>) {
195 for part in &word.0 {
196 match part {
197 WordPart::CmdSub(s) | WordPart::ProcSub(s) => collect_script(s, out),
198 WordPart::DQuote(w) => collect_word(w, out),
199 _ => {}
200 }
201 }
202}
203
204fn is_known(name: &str) -> bool {
208 known_names().contains(registry::canonical_name(name))
209}
210
211fn known_names() -> &'static BTreeSet<String> {
212 static KNOWN: OnceLock<BTreeSet<String>> = OnceLock::new();
213 KNOWN.get_or_init(|| {
214 let mut set: BTreeSet<String> = crate::docs::all_command_docs()
215 .into_iter()
216 .map(|d| d.name)
217 .collect();
218 for name in registry::toml_command_names() {
219 set.insert(name.to_string());
220 }
221 set
222 })
223}
224
225pub fn render_toml(entries: &[GeneratedEntry]) -> String {
228 let mut out = String::new();
229 for (i, entry) in entries.iter().enumerate() {
230 if i > 0 {
231 out.push('\n');
232 }
233 out.push_str("[[command]]\n");
234 out.push_str(&format!("name = {}\n", toml_str(&entry.name)));
235 if !entry.standalone.is_empty() {
236 let items: Vec<String> = entry.standalone.iter().map(|f| toml_str(f)).collect();
237 out.push_str(&format!("standalone = [{}]\n", items.join(", ")));
238 }
239 out.push_str(&format!("max_positional = {}\n", entry.max_positional));
240 out.push_str(&format!("level = {}\n", toml_str(&entry.level)));
241 }
242 out
243}
244
245fn toml_str(s: &str) -> String {
248 let mut out = String::from("\"");
249 for c in s.chars() {
250 match c {
251 '"' => out.push_str("\\\""),
252 '\\' => out.push_str("\\\\"),
253 '\n' => out.push_str("\\n"),
254 '\r' => out.push_str("\\r"),
255 '\t' => out.push_str("\\t"),
256 c if (c as u32) < 0x20 || c == '\u{7f}' => {
257 out.push_str(&format!("\\u{:04X}", c as u32));
258 }
259 c => out.push(c),
260 }
261 }
262 out.push('"');
263 out
264}
265
266pub fn config_hash(bytes: &[u8]) -> String {
269 Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
270}
271
272pub fn merged_content(existing: &str, entries: &[GeneratedEntry]) -> String {
275 let block = render_toml(entries);
276 if existing.trim().is_empty() {
277 return block;
278 }
279 let mut content = existing.to_string();
280 if !content.ends_with('\n') {
281 content.push('\n');
282 }
283 content.push('\n');
284 content.push_str(&block);
285 content
286}
287
288pub fn pin_block(canonical_dir: &str, hash: &str) -> String {
290 format!(
291 "[[trusted]]\npath = {}\nsha256 = {}\n",
292 toml_str(canonical_dir),
293 toml_str(hash),
294 )
295}
296
297#[cfg(test)]
298mod tests;