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::FunctionDef { body, .. } => collect_script(body, out),
180 }
181}
182
183fn collect_word<'a>(word: &'a Word, out: &mut Vec<&'a SimpleCmd>) {
189 for part in &word.0 {
190 match part {
191 WordPart::CmdSub(s) | WordPart::ProcSub(s) => collect_script(s, out),
192 WordPart::DQuote(w) => collect_word(w, out),
193 _ => {}
194 }
195 }
196}
197
198fn is_known(name: &str) -> bool {
202 known_names().contains(registry::canonical_name(name))
203}
204
205fn known_names() -> &'static BTreeSet<String> {
206 static KNOWN: OnceLock<BTreeSet<String>> = OnceLock::new();
207 KNOWN.get_or_init(|| {
208 let mut set: BTreeSet<String> = crate::docs::all_command_docs()
209 .into_iter()
210 .map(|d| d.name)
211 .collect();
212 for name in registry::toml_command_names() {
213 set.insert(name.to_string());
214 }
215 set
216 })
217}
218
219pub fn render_toml(entries: &[GeneratedEntry]) -> String {
222 let mut out = String::new();
223 for (i, entry) in entries.iter().enumerate() {
224 if i > 0 {
225 out.push('\n');
226 }
227 out.push_str("[[command]]\n");
228 out.push_str(&format!("name = {}\n", toml_str(&entry.name)));
229 if !entry.standalone.is_empty() {
230 let items: Vec<String> = entry.standalone.iter().map(|f| toml_str(f)).collect();
231 out.push_str(&format!("standalone = [{}]\n", items.join(", ")));
232 }
233 out.push_str(&format!("max_positional = {}\n", entry.max_positional));
234 out.push_str(&format!("level = {}\n", toml_str(&entry.level)));
235 }
236 out
237}
238
239fn toml_str(s: &str) -> String {
242 let mut out = String::from("\"");
243 for c in s.chars() {
244 match c {
245 '"' => out.push_str("\\\""),
246 '\\' => out.push_str("\\\\"),
247 '\n' => out.push_str("\\n"),
248 '\r' => out.push_str("\\r"),
249 '\t' => out.push_str("\\t"),
250 c if (c as u32) < 0x20 || c == '\u{7f}' => {
251 out.push_str(&format!("\\u{:04X}", c as u32));
252 }
253 c => out.push(c),
254 }
255 }
256 out.push('"');
257 out
258}
259
260pub fn config_hash(bytes: &[u8]) -> String {
263 Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
264}
265
266pub fn merged_content(existing: &str, entries: &[GeneratedEntry]) -> String {
269 let block = render_toml(entries);
270 if existing.trim().is_empty() {
271 return block;
272 }
273 let mut content = existing.to_string();
274 if !content.ends_with('\n') {
275 content.push('\n');
276 }
277 content.push('\n');
278 content.push_str(&block);
279 content
280}
281
282pub fn pin_block(canonical_dir: &str, hash: &str) -> String {
284 format!(
285 "[[trusted]]\npath = {}\nsha256 = {}\n",
286 toml_str(canonical_dir),
287 toml_str(hash),
288 )
289}
290
291#[cfg(test)]
292mod tests;