1use crate::registry::{Effect, PathSource, Registry, Rule};
30use brush_parser::ast;
31use brush_parser::word::{TildeExpr, WordPiece, WordPieceWithSource};
32use std::collections::BTreeSet;
33use std::path::{Component, Path, PathBuf};
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Severity {
42 #[default]
43 Safe,
44 Mutating,
45 Externalizing,
46 Unknown,
47 Destructive,
48 Irreversible,
49}
50
51impl Severity {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Severity::Safe => "safe",
55 Severity::Mutating => "mutating",
56 Severity::Externalizing => "externalizing",
57 Severity::Unknown => "unknown",
58 Severity::Destructive => "destructive",
59 Severity::Irreversible => "irreversible",
60 }
61 }
62}
63
64impl From<Effect> for Severity {
65 fn from(e: Effect) -> Self {
66 match e {
67 Effect::Safe => Severity::Safe,
68 Effect::Mutating => Severity::Mutating,
69 Effect::Externalizing => Severity::Externalizing,
70 Effect::Destructive => Severity::Destructive,
71 Effect::Irreversible => Severity::Irreversible,
72 }
73 }
74}
75
76pub struct Ctx<'a> {
77 pub cwd: &'a Path,
78 pub home: &'a Path,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Resolution {
83 pub severity: Severity,
84 pub paths: Vec<PathBuf>,
86 pub rule_id: Option<String>,
88 pub has_unknown: bool,
90}
91
92const OPAQUE_COMMANDS: &[&str] = &[
95 "eval", "source", ".", "exec", "sh", "bash", "zsh", "dash", "ksh", "fish", "xargs", "sudo",
96 "doas", "env", "nohup", "nice", "time", "timeout", "command", "builtin", "script", "watch",
97];
98
99const GLOB_CHARS: &[char] = &['*', '?', '['];
100
101const MAX_WALK_DEPTH: usize = 256;
105const MAX_LITERAL_DEPTH: usize = 64;
106
107const MAX_BRACE_EXPANSION: usize = 1024;
110
111const RESOLVER_STACK_BYTES: usize = 32 * 1024 * 1024;
114
115pub fn resolve(command: &str, registry: &Registry, ctx: &Ctx) -> Resolution {
116 std::thread::scope(|scope| {
117 let handle = std::thread::Builder::new()
118 .name("doover-resolver".into())
119 .stack_size(RESOLVER_STACK_BYTES)
120 .spawn_scoped(scope, || resolve_inner(command, registry, ctx));
121 match handle.map(std::thread::ScopedJoinHandle::join) {
122 Ok(Ok(resolution)) => resolution,
123 _ => Resolution {
124 severity: Severity::Unknown,
125 paths: Vec::new(),
126 rule_id: None,
127 has_unknown: true,
128 },
129 }
130 })
131}
132
133fn resolve_inner(command: &str, registry: &Registry, ctx: &Ctx) -> Resolution {
134 let mut walker = Walker {
135 registry,
136 ctx,
137 options: brush_parser::ParserOptions::default(),
138 out: Out::default(),
139 };
140 let mut cur = Some(normalize_lexical(ctx.cwd));
141 match brush_parser::tokenize_str(command) {
142 Ok(tokens) => match brush_parser::parse_tokens(&tokens, &walker.options) {
143 Ok(program) => {
144 for complete_command in &program.complete_commands {
145 walker.walk_compound_list(complete_command, &mut cur, 0);
146 }
147 }
148 Err(_) => walker.out.mark_unknown(),
149 },
150 Err(_) => walker.out.mark_unknown(),
151 }
152 walker.out.finish()
153}
154
155#[derive(Default)]
156struct Out {
157 severity: Severity,
158 paths: BTreeSet<PathBuf>,
159 rule_id: Option<String>,
160 rule_sev: Severity,
161 has_unknown: bool,
162}
163
164impl Out {
165 fn mark_unknown(&mut self) {
166 self.severity = self.severity.max(Severity::Unknown);
167 self.has_unknown = true;
168 }
169
170 fn contribute(&mut self, sev: Severity, rule_id: &str) {
171 self.severity = self.severity.max(sev);
172 if self.rule_id.is_none() || sev > self.rule_sev {
173 self.rule_sev = sev;
174 self.rule_id = Some(rule_id.to_string());
175 }
176 }
177
178 fn finish(self) -> Resolution {
179 Resolution {
180 severity: self.severity,
181 paths: self.paths.into_iter().collect(),
182 rule_id: self.rule_id,
183 has_unknown: self.has_unknown,
184 }
185 }
186}
187
188#[derive(Clone, Debug)]
193struct Word {
194 segs: Vec<(String, bool)>,
195 tilde_home: bool,
196}
197
198impl Word {
199 fn text(&self) -> String {
200 self.segs.iter().map(|(s, _)| s.as_str()).collect()
201 }
202
203 fn masked_chars(&self) -> Vec<(char, bool)> {
204 self.segs
205 .iter()
206 .flat_map(|(s, exp)| s.chars().map(move |c| (c, *exp)))
207 .collect()
208 }
209
210 fn has_expandable(&self, ch: char) -> bool {
211 self.segs.iter().any(|(s, exp)| *exp && s.contains(ch))
212 }
213
214 fn split_off_bytes(&self, at: usize) -> Word {
217 let mut consumed = 0usize;
218 let mut segs = Vec::new();
219 for (s, exp) in &self.segs {
220 let end = consumed + s.len();
221 if end > at {
222 let start = at.saturating_sub(consumed);
223 segs.push((s[start..].to_string(), *exp));
224 }
225 consumed = end;
226 }
227 Word {
228 segs,
229 tilde_home: false,
230 }
231 }
232}
233
234enum ArgVal {
235 Lit(Word),
236 Opaque,
237}
238
239enum Tok {
240 Flag(Word),
241 Pos(Word),
242}
243
244enum Consume {
246 No,
247 Drop,
248 Path,
249}
250
251struct Walker<'a> {
252 registry: &'a Registry,
253 ctx: &'a Ctx<'a>,
254 options: brush_parser::ParserOptions,
255 out: Out,
256}
257
258impl Walker<'_> {
259 fn walk_compound_list(
262 &mut self,
263 list: &ast::CompoundList,
264 cur: &mut Option<PathBuf>,
265 depth: usize,
266 ) {
267 if depth > MAX_WALK_DEPTH {
268 self.out.mark_unknown();
269 return;
270 }
271 for ast::CompoundListItem(and_or, _sep) in &list.0 {
272 self.walk_pipeline(&and_or.first, cur, depth);
273 for branch in &and_or.additional {
274 let (ast::AndOr::And(p) | ast::AndOr::Or(p)) = branch;
275 self.walk_pipeline(p, cur, depth);
276 }
277 }
278 }
279
280 fn walk_pipeline(&mut self, pipeline: &ast::Pipeline, cur: &mut Option<PathBuf>, depth: usize) {
281 if pipeline.seq.len() == 1 {
282 self.walk_command(&pipeline.seq[0], cur, depth);
283 } else {
284 for command in &pipeline.seq {
286 let mut seg_cur = cur.clone();
287 self.walk_command(command, &mut seg_cur, depth);
288 }
289 }
290 }
291
292 fn walk_command(&mut self, command: &ast::Command, cur: &mut Option<PathBuf>, depth: usize) {
293 match command {
294 ast::Command::Simple(simple) => self.handle_simple(simple, cur),
295 ast::Command::Compound(compound, redirects) => {
296 match compound {
297 ast::CompoundCommand::Subshell(subshell) => {
298 let mut sub_cur = cur.clone();
299 self.walk_compound_list(&subshell.list, &mut sub_cur, depth + 1);
300 }
301 ast::CompoundCommand::BraceGroup(group) => {
302 self.walk_compound_list(&group.list, cur, depth + 1);
303 }
304 _ => self.out.mark_unknown(),
307 }
308 if let Some(list) = redirects {
309 for redirect in &list.0 {
310 self.handle_redirect(redirect, cur);
311 }
312 }
313 }
314 ast::Command::Function(_) => {}
317 ast::Command::ExtendedTest(_, redirects) => {
319 if let Some(list) = redirects {
320 for redirect in &list.0 {
321 self.handle_redirect(redirect, cur);
322 }
323 }
324 }
325 }
326 }
327
328 fn handle_simple(&mut self, simple: &ast::SimpleCommand, cur: &mut Option<PathBuf>) {
329 if let Some(prefix) = &simple.prefix {
330 self.handle_prefix_suffix_items(&prefix.0, None, cur);
331 }
332 let mut args: Vec<ArgVal> = Vec::new();
335 if let Some(suffix) = &simple.suffix {
336 self.handle_prefix_suffix_items(&suffix.0, Some(&mut args), cur);
337 }
338
339 let Some(name_word) = &simple.word_or_name else {
340 return; };
342 let Some(name_lit) = self.extract_word(&name_word.value) else {
343 self.out.mark_unknown();
344 return;
345 };
346 if name_lit.tilde_home {
347 self.out.mark_unknown();
348 return;
349 }
350 let name = name_lit.text();
351
352 if name == "cd" {
353 self.handle_cd(&args, cur);
354 return;
355 }
356 if OPAQUE_COMMANDS.contains(&name.as_str()) {
357 self.out.mark_unknown();
358 return;
359 }
360
361 let mut any_opaque = false;
362 let mut tokens: Vec<Tok> = Vec::new();
363 let mut after_double_dash = false;
364 for arg in &args {
365 match arg {
366 ArgVal::Opaque => any_opaque = true,
367 ArgVal::Lit(word) => {
368 let text = word.text();
369 if !after_double_dash && text == "--" {
370 after_double_dash = true;
371 } else if !after_double_dash && text.len() > 1 && text.starts_with('-') {
372 tokens.push(Tok::Flag(word.clone()));
373 } else {
374 tokens.push(Tok::Pos(word.clone()));
375 }
376 }
377 }
378 }
379 if any_opaque {
380 self.out.mark_unknown();
381 }
382
383 let flags: Vec<String> = tokens
384 .iter()
385 .filter_map(|t| match t {
386 Tok::Flag(w) => Some(w.text()),
387 Tok::Pos(_) => None,
388 })
389 .collect();
390 let subs: Vec<String> = tokens
391 .iter()
392 .filter_map(|t| match t {
393 Tok::Pos(w) => Some(w.text()),
394 Tok::Flag(_) => None,
395 })
396 .collect();
397 let Some(rule) =
398 self.registry
399 .lookup_command(&name, subs.first().map(String::as_str), &flags)
400 else {
401 self.out.mark_unknown();
402 return;
403 };
404 let sev = Severity::from(rule.effect);
405 self.out.contribute(sev, &rule.id);
406
407 let empty: &[String] = &[];
412 let (flag_args, path_flags) = rule
413 .scope
414 .as_ref()
415 .map(|s| (s.flag_args.as_slice(), s.path_flags.as_slice()))
416 .unwrap_or((empty, empty));
417 let is_short = |f: &str| f.len() == 2 && f.starts_with('-') && !f.starts_with("--");
418 let mut positionals: Vec<Word> = Vec::new();
419 let mut flag_paths: Vec<Word> = Vec::new();
420 let mut consume: Consume = Consume::No;
421 for tok in &tokens {
422 match tok {
423 Tok::Flag(w) => {
424 let t = w.text();
425 if let Some(eq) = t.find('=') {
426 if path_flags.iter().any(|pf| pf.as_str() == &t[..eq]) {
427 flag_paths.push(w.split_off_bytes(eq + 1));
428 }
429 } else if path_flags.iter().any(|pf| pf.as_str() == t) {
430 consume = Consume::Path;
431 } else if flag_args.iter().any(|fa| fa.as_str() == t) {
432 consume = Consume::Drop;
433 } else if let Some(pf) = path_flags
434 .iter()
435 .find(|pf| is_short(pf) && t.starts_with(pf.as_str()) && t.len() > 2)
436 {
437 flag_paths.push(w.split_off_bytes(pf.len()));
438 } else if flag_args
439 .iter()
440 .any(|fa| is_short(fa) && t.starts_with(fa.as_str()) && t.len() > 2)
441 {
442 }
444 }
445 Tok::Pos(w) => match std::mem::replace(&mut consume, Consume::No) {
446 Consume::Path => flag_paths.push(w.clone()),
447 Consume::Drop => {}
448 Consume::No => positionals.push(w.clone()),
449 },
450 }
451 }
452
453 let mut contributed = self.extract_scope(rule, &positionals, cur);
454 if let Some(scope) = &rule.scope {
455 for pv in &flag_paths {
456 contributed += self.add_path(pv, scope.globs, cur, true);
459 }
460 }
461 if sev >= Severity::Destructive && contributed == 0 {
462 self.out.mark_unknown();
468 }
469 }
470
471 fn handle_prefix_suffix_items(
472 &mut self,
473 items: &[ast::CommandPrefixOrSuffixItem],
474 mut args: Option<&mut Vec<ArgVal>>,
475 cur: &Option<PathBuf>,
476 ) {
477 for item in items {
478 match item {
479 ast::CommandPrefixOrSuffixItem::Word(word) => {
480 if let Some(args) = args.as_deref_mut() {
481 args.push(match self.extract_word(&word.value) {
482 Some(lit) => ArgVal::Lit(lit),
483 None => ArgVal::Opaque,
484 });
485 }
486 }
487 ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
488 self.handle_redirect(redirect, cur);
489 }
490 ast::CommandPrefixOrSuffixItem::AssignmentWord(_assignment, word) => {
491 if self.word_can_execute(&word.value) {
494 self.out.mark_unknown();
495 }
496 }
497 ast::CommandPrefixOrSuffixItem::ProcessSubstitution(..) => {
498 self.out.mark_unknown();
499 }
500 }
501 }
502 }
503
504 fn extract_scope(&mut self, rule: &Rule, positionals: &[Word], cur: &Option<PathBuf>) -> usize {
505 let Some(scope) = &rule.scope else { return 0 };
506 let skip = usize::from(rule.matcher.subcommand.is_some()) + scope.skip;
507 let path_args: Vec<&Word> = positionals.iter().skip(skip).collect();
508 let mut contributed = 0usize;
509 match scope.paths {
510 PathSource::Positional => {
511 for word in path_args {
512 contributed += self.add_path(word, scope.globs, cur, false);
513 }
514 }
515 PathSource::PositionalLast => {
516 if let Some(word) = path_args.last() {
517 contributed += self.add_path(word, scope.globs, cur, false);
518 }
519 }
520 PathSource::Repo => {
521 if let Some(root) = cur.as_deref().and_then(find_repo_root) {
522 self.insert_scoped(root, false);
523 contributed += 1;
524 }
525 }
526 PathSource::RedirectTarget | PathSource::None => {}
527 }
528 contributed
529 }
530
531 fn add_path(
539 &mut self,
540 word: &Word,
541 globs: bool,
542 cur: &Option<PathBuf>,
543 write_target: bool,
544 ) -> usize {
545 if word.segs.is_empty() && !word.tilde_home {
546 return 0;
547 }
548 let chars = word.masked_chars();
549 let expanded: Vec<Vec<(char, bool)>> = if word.has_expandable('{') {
550 match expand_braces_masked(&chars, MAX_BRACE_EXPANSION) {
551 Some(list) => list,
552 None => {
553 self.out.mark_unknown();
555 return 0;
556 }
557 }
558 } else {
559 vec![chars]
560 };
561 let mut total = 0;
562 for variant in expanded {
563 total += self.add_single(&variant, word.tilde_home, globs, cur, write_target);
564 }
565 total
566 }
567
568 fn add_single(
571 &mut self,
572 chars: &[(char, bool)],
573 tilde_home: bool,
574 globs: bool,
575 cur: &Option<PathBuf>,
576 write_target: bool,
577 ) -> usize {
578 let text: String = chars.iter().map(|(c, _)| c).collect();
579 if text.is_empty() && !tilde_home {
580 return 0;
581 }
582 if text.ends_with("/..") {
585 self.out.mark_unknown();
586 return 0;
587 }
588
589 let (base, rem): (Option<PathBuf>, &[(char, bool)]) = if tilde_home {
591 let mut start = 0;
592 while start < chars.len() && chars[start].0 == '/' {
593 start += 1;
594 }
595 (Some(self.ctx.home.to_path_buf()), &chars[start..])
596 } else if Path::new(&text).is_absolute() {
597 (None, chars)
598 } else {
599 match cur {
600 Some(dir) => (Some(dir.clone()), chars),
601 None => {
602 self.out.mark_unknown();
603 return 0;
604 }
605 }
606 };
607 let rem_text: String = rem.iter().map(|(c, _)| c).collect();
608 let literal = match &base {
609 Some(b) => normalize_lexical(&b.join(&rem_text)),
610 None => normalize_lexical(Path::new(&rem_text)),
611 };
612
613 let active_glob = rem.iter().any(|(c, exp)| *exp && GLOB_CHARS.contains(c));
614 if !(globs && active_glob) {
615 let derefs = write_target || rem_text.ends_with('/') || rem_text.ends_with("/.");
619 self.insert_scoped(literal, derefs);
620 return 1;
621 }
622
623 let mut pattern = String::new();
626 if let Some(b) = &base {
627 pattern.push_str(&glob::Pattern::escape(&b.to_string_lossy()));
628 pattern.push('/');
629 }
630 for (c, exp) in rem {
631 if !exp && GLOB_CHARS.contains(c) {
632 pattern.push_str(&glob::Pattern::escape(&c.to_string()));
633 } else {
634 pattern.push(*c);
635 }
636 }
637 let base_prefix = base.as_ref().map(|b| format!("{}/", b.to_string_lossy()));
644 let pat_components: Vec<&str> = rem_text.trim_matches('/').split('/').collect();
645 match glob::glob(&pattern) {
646 Ok(matches) => {
647 let mut n = 0usize;
648 for m in matches.take(10_000).flatten() {
649 let m_str = m.to_string_lossy();
650 let relative = match &base_prefix {
651 Some(p) => m_str.strip_prefix(p.as_str()).unwrap_or(&m_str),
652 None => m_str.trim_start_matches('/'),
653 };
654 if glob_match_allowed(relative, &pat_components) {
655 self.insert_scoped(normalize_lexical(&m), false);
658 n += 1;
659 }
660 }
661 n
662 }
663 Err(_) => {
664 self.insert_scoped(literal, false);
665 1
666 }
667 }
668 }
669
670 fn insert_scoped(&mut self, path: PathBuf, deref: bool) {
679 let mut hops = 0;
680 let mut cur = path;
681 loop {
682 let is_link = deref && cur.symlink_metadata().is_ok_and(|m| m.is_symlink());
683 self.out.paths.insert(cur.clone());
684 if !is_link || hops >= 8 {
685 return;
686 }
687 let Ok(target) = std::fs::read_link(&cur) else {
688 return;
689 };
690 let resolved = if target.is_absolute() {
691 normalize_lexical(&target)
692 } else {
693 match cur.parent() {
694 Some(parent) => normalize_lexical(&parent.join(target)),
695 None => return,
696 }
697 };
698 if self.out.paths.contains(&resolved) {
699 return; }
701 cur = resolved;
702 hops += 1;
703 }
704 }
705
706 fn resolve_literal(&mut self, word: &Word, cur: &Option<PathBuf>) -> Option<PathBuf> {
709 let text = word.text();
710 if word.tilde_home {
711 return Some(normalize_lexical(
712 &self.ctx.home.join(text.trim_start_matches('/')),
713 ));
714 }
715 if Path::new(&text).is_absolute() {
716 return Some(normalize_lexical(Path::new(&text)));
717 }
718 match cur {
719 Some(dir) => Some(normalize_lexical(&dir.join(&text))),
720 None => {
721 self.out.mark_unknown();
722 None
723 }
724 }
725 }
726
727 fn handle_cd(&mut self, args: &[ArgVal], cur: &mut Option<PathBuf>) {
728 let target = args.iter().find(|a| match a {
729 ArgVal::Lit(w) => {
730 let t = w.text();
731 !(t.len() > 1 && t.starts_with('-')) && t != "--"
732 }
733 ArgVal::Opaque => true,
734 });
735 match target {
736 None => *cur = Some(normalize_lexical(self.ctx.home)),
737 Some(ArgVal::Lit(w)) if w.text() == "-" => {
738 self.out.mark_unknown();
740 *cur = None;
741 }
742 Some(ArgVal::Lit(w)) => match self.resolve_literal(w, cur) {
743 Some(p) => *cur = Some(p),
744 None => *cur = None,
745 },
746 Some(ArgVal::Opaque) => {
747 self.out.mark_unknown();
748 *cur = None;
749 }
750 }
751 }
752
753 fn handle_redirect(&mut self, redirect: &ast::IoRedirect, cur: &Option<PathBuf>) {
754 use ast::{IoFileRedirectKind as Kind, IoFileRedirectTarget as Target, IoRedirect as R};
755 match redirect {
756 R::File(_fd, kind, target) => {
757 let op = match kind {
758 Kind::Write | Kind::Clobber | Kind::ReadAndWrite => ">",
759 Kind::Append => ">>",
760 Kind::Read | Kind::DuplicateInput | Kind::DuplicateOutput => {
764 if let Target::Filename(word) = target {
765 if self.word_can_execute(&word.value) {
766 self.out.mark_unknown();
767 }
768 }
769 return;
770 }
771 };
772 match target {
773 Target::Filename(word) => self.redirect_to(op, &word.value, cur),
774 Target::Fd(_) | Target::Duplicate(_) => {}
775 Target::ProcessSubstitution(..) => self.out.mark_unknown(),
776 }
777 }
778 R::OutputAndError(word, append) => {
780 let op = if *append { ">>" } else { ">" };
781 self.redirect_to(op, &word.value, cur);
782 }
783 R::HereString(_fd, word) => {
785 if self.word_can_execute(&word.value) {
786 self.out.mark_unknown();
787 }
788 }
789 R::HereDocument(_fd, here) => {
791 if here.requires_expansion && self.word_can_execute(&here.doc.value) {
792 self.out.mark_unknown();
793 }
794 }
795 }
796 }
797
798 fn redirect_to(&mut self, op: &str, raw: &str, cur: &Option<PathBuf>) {
799 let Some(word) = self.extract_word(raw) else {
800 self.out.mark_unknown();
801 return;
802 };
803 let text = word.text();
804 if (text.is_empty() && !word.tilde_home) || text.chars().all(|ch| ch.is_ascii_digit()) {
806 return;
807 }
808 let Some(rule) = self.registry.lookup_redirect(op) else {
809 self.out.mark_unknown();
810 return;
811 };
812 self.out.contribute(Severity::from(rule.effect), &rule.id);
813 if let Some(p) = self.resolve_literal(&word, cur) {
814 self.insert_scoped(p, true);
817 }
818 }
819
820 fn extract_word(&self, raw: &str) -> Option<Word> {
821 let pieces = brush_parser::word::parse(raw, &self.options).ok()?;
822 pieces_to_word(&pieces, 0)
823 }
824
825 fn word_can_execute(&self, raw: &str) -> bool {
830 fn scan(pieces: &[WordPieceWithSource], raw: &str, depth: usize) -> bool {
831 depth > MAX_LITERAL_DEPTH
832 || pieces.iter().any(|pw| match &pw.piece {
833 WordPiece::CommandSubstitution(_)
834 | WordPiece::BackquotedCommandSubstitution(_) => true,
835 WordPiece::DoubleQuotedSequence(inner)
836 | WordPiece::GettextDoubleQuotedSequence(inner) => scan(inner, raw, depth + 1),
837 WordPiece::ParameterExpansion(_) | WordPiece::ArithmeticExpression(_) => {
841 raw.contains("$(") || raw.contains('`')
842 }
843 _ => false,
844 })
845 }
846 match brush_parser::word::parse(raw, &self.options) {
847 Ok(pieces) => scan(&pieces, raw, 0),
848 Err(_) => true,
849 }
850 }
851}
852
853fn pieces_to_word(pieces: &[WordPieceWithSource], depth: usize) -> Option<Word> {
856 if depth > MAX_LITERAL_DEPTH {
857 return None;
858 }
859 let mut word = Word {
860 segs: Vec::new(),
861 tilde_home: false,
862 };
863 for (i, pw) in pieces.iter().enumerate() {
864 match &pw.piece {
865 WordPiece::Text(s) => word.segs.push((s.clone(), true)),
866 WordPiece::SingleQuotedText(s) | WordPiece::AnsiCQuotedText(s) => {
867 word.segs.push((s.clone(), false));
868 }
869 WordPiece::DoubleQuotedSequence(inner)
870 | WordPiece::GettextDoubleQuotedSequence(inner) => {
871 let lit = pieces_to_word(inner, depth + 1)?;
874 if lit.tilde_home {
875 return None; }
877 word.segs.push((lit.text(), false));
878 }
879 WordPiece::EscapeSequence(s) => {
880 word.segs
881 .push((s.strip_prefix('\\').unwrap_or(s).to_string(), false));
882 }
883 WordPiece::TildeExpansion(TildeExpr::Home) if i == 0 => {
884 word.tilde_home = true;
885 }
886 _ => return None,
889 }
890 }
891 Some(word)
892}
893
894fn expand_braces_masked(chars: &[(char, bool)], limit: usize) -> Option<Vec<Vec<(char, bool)>>> {
898 let mut out = Vec::new();
899 if expand_rec(chars, &mut out, limit) {
900 Some(out)
901 } else {
902 None
903 }
904}
905
906fn expand_rec(chars: &[(char, bool)], out: &mut Vec<Vec<(char, bool)>>, limit: usize) -> bool {
907 if out.len() > limit {
908 return false;
909 }
910 for i in 0..chars.len() {
911 if chars[i] != ('{', true) {
912 continue;
913 }
914 match parse_brace(chars, i, limit) {
915 BraceParse::Expand(close, options) => {
916 for opt in options {
917 let mut next = chars[..i].to_vec();
918 next.extend(opt);
919 next.extend_from_slice(&chars[close + 1..]);
920 if !expand_rec(&next, out, limit) {
921 return false;
922 }
923 }
924 return true;
925 }
926 BraceParse::Overflow => return false,
927 BraceParse::NotHere => {}
928 }
929 }
930 out.push(chars.to_vec());
931 out.len() <= limit
932}
933
934enum BraceParse {
935 Expand(usize, Vec<Vec<(char, bool)>>),
936 Overflow,
937 NotHere,
938}
939
940fn parse_brace(chars: &[(char, bool)], open: usize, limit: usize) -> BraceParse {
941 let mut depth = 0usize;
942 let mut close = None;
943 let mut commas = Vec::new();
944 for (i, &(c, exp)) in chars.iter().enumerate().skip(open) {
945 if !exp {
946 continue; }
948 match c {
949 '{' => depth += 1,
950 '}' => {
951 depth -= 1;
952 if depth == 0 {
953 close = Some(i);
954 break;
955 }
956 }
957 ',' if depth == 1 => commas.push(i),
958 _ => {}
959 }
960 }
961 let Some(close) = close else {
962 return BraceParse::NotHere;
963 };
964 if !commas.is_empty() {
965 let mut options = Vec::new();
966 let mut start = open + 1;
967 for &c in &commas {
968 options.push(chars[start..c].to_vec());
969 start = c + 1;
970 }
971 options.push(chars[start..close].to_vec());
972 return BraceParse::Expand(close, options);
973 }
974 let interior = &chars[open + 1..close];
976 if interior.is_empty() || interior.iter().any(|(_, exp)| !exp) {
977 return BraceParse::NotHere;
978 }
979 let inner: String = interior.iter().map(|(c, _)| c).collect();
980 match parse_range(&inner, limit) {
981 Some(Some(options)) => BraceParse::Expand(
982 close,
983 options
984 .into_iter()
985 .map(|s| s.chars().map(|c| (c, true)).collect())
986 .collect(),
987 ),
988 Some(None) => BraceParse::Overflow,
989 None => BraceParse::NotHere, }
991}
992
993fn parse_range(inner: &str, limit: usize) -> Option<Option<Vec<String>>> {
1003 let parts: Vec<&str> = inner.split("..").collect();
1004 if parts.len() != 2 && parts.len() != 3 {
1005 return None;
1006 }
1007 if parts.len() == 3 {
1008 let looks_like_range = parts[2].parse::<i128>().is_ok();
1010 return if looks_like_range { Some(None) } else { None };
1011 }
1012 let step: i128 = 1;
1013
1014 let make = |a: i128, b: i128, fmt: &dyn Fn(i128) -> String| -> Option<Vec<String>> {
1015 let count = (a - b).unsigned_abs() / step.unsigned_abs() + 1;
1016 if count > limit as u128 {
1017 return None;
1018 }
1019 let dir: i128 = if a <= b { step } else { -step };
1020 let mut v = Vec::with_capacity(count as usize);
1021 let mut cur = a;
1022 while (dir > 0 && cur <= b) || (dir < 0 && cur >= b) {
1023 v.push(fmt(cur));
1024 cur += dir;
1025 }
1026 Some(v)
1027 };
1028
1029 if let (Ok(a), Ok(b)) = (parts[0].parse::<i128>(), parts[1].parse::<i128>()) {
1031 let zero_padded = (parts[0].starts_with('0') && parts[0].len() > 1)
1032 || (parts[1].starts_with('0') && parts[1].len() > 1)
1033 || parts[0].starts_with("-0")
1034 || parts[1].starts_with("-0");
1035 if zero_padded {
1036 return Some(None);
1038 }
1039 return Some(make(a, b, &|n| n.to_string()));
1040 }
1041
1042 let (sc, ec) = (
1044 parts[0].chars().collect::<Vec<_>>(),
1045 parts[1].chars().collect::<Vec<_>>(),
1046 );
1047 if sc.len() == 1 && ec.len() == 1 && sc[0].is_ascii_alphabetic() && ec[0].is_ascii_alphabetic()
1048 {
1049 return Some(make(sc[0] as i128, ec[0] as i128, &|n| {
1050 ((n as u8) as char).to_string()
1051 }));
1052 }
1053 None
1054}
1055
1056fn glob_match_allowed(relative: &str, pat_components: &[&str]) -> bool {
1061 let pats: Vec<&str> = pat_components
1064 .iter()
1065 .copied()
1066 .filter(|p| !p.is_empty())
1067 .collect();
1068 for (i, comp) in relative.split('/').filter(|c| !c.is_empty()).enumerate() {
1069 if comp == "." || comp == ".." {
1070 return false;
1071 }
1072 if comp.starts_with('.') {
1073 let pat_dotted = pats.get(i).is_some_and(|p| p.starts_with('.'));
1074 if !pat_dotted {
1075 return false;
1076 }
1077 }
1078 }
1079 true
1080}
1081
1082pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
1084 start
1085 .ancestors()
1086 .find(|p| p.join(".git").exists())
1087 .map(normalize_lexical)
1088}
1089
1090pub fn normalize_lexical(path: &Path) -> PathBuf {
1093 let mut out = PathBuf::new();
1094 for comp in path.components() {
1095 match comp {
1096 Component::CurDir => {}
1097 Component::ParentDir => {
1098 if !out.pop() {
1099 out.push("..");
1100 }
1101 }
1102 other => out.push(other.as_os_str()),
1103 }
1104 }
1105 out
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 fn masked(parts: &[(&str, bool)]) -> Vec<(char, bool)> {
1113 parts
1114 .iter()
1115 .flat_map(|(s, e)| s.chars().map(move |c| (c, *e)))
1116 .collect()
1117 }
1118
1119 fn texts(result: Option<Vec<Vec<(char, bool)>>>) -> Option<Vec<String>> {
1120 result.map(|list| {
1121 list.into_iter()
1122 .map(|cs| cs.into_iter().map(|(c, _)| c).collect())
1123 .collect()
1124 })
1125 }
1126
1127 #[test]
1128 fn unquoted_group_expands() {
1129 let r = texts(expand_braces_masked(&masked(&[("{a,b}", true)]), 64));
1130 assert_eq!(r, Some(vec!["a".into(), "b".into()]));
1131 }
1132
1133 #[test]
1134 fn quoted_group_is_inert() {
1135 let r = texts(expand_braces_masked(&masked(&[("{a,b}", false)]), 64));
1136 assert_eq!(r, Some(vec!["{a,b}".into()]));
1137 }
1138
1139 #[test]
1140 fn quoted_prefix_with_unquoted_group() {
1141 let r = texts(expand_braces_masked(
1143 &masked(&[("{a,b}", false), ("{c,d}", true)]),
1144 64,
1145 ));
1146 assert_eq!(r, Some(vec!["{a,b}c".into(), "{a,b}d".into()]));
1147 }
1148
1149 #[test]
1150 fn cartesian_product() {
1151 let r = texts(expand_braces_masked(&masked(&[("{a,b}{1,2}", true)]), 64));
1152 assert_eq!(
1153 r,
1154 Some(vec!["a1".into(), "a2".into(), "b1".into(), "b2".into()])
1155 );
1156 }
1157
1158 #[test]
1159 fn nested_groups() {
1160 let r = texts(expand_braces_masked(&masked(&[("{a,b{1,2}}", true)]), 64));
1161 assert_eq!(r, Some(vec!["a".into(), "b1".into(), "b2".into()]));
1162 }
1163
1164 #[test]
1165 fn comma_free_brace_is_literal() {
1166 let r = texts(expand_braces_masked(&masked(&[("x{alone}y", true)]), 64));
1167 assert_eq!(r, Some(vec!["x{alone}y".into()]));
1168 }
1169
1170 #[test]
1171 fn portable_ranges_expand() {
1172 assert_eq!(
1173 texts(expand_braces_masked(&masked(&[("{1..3}", true)]), 64)),
1174 Some(vec!["1".into(), "2".into(), "3".into()])
1175 );
1176 assert_eq!(
1177 texts(expand_braces_masked(&masked(&[("{c..a}", true)]), 64)),
1178 Some(vec!["c".into(), "b".into(), "a".into()])
1179 );
1180 }
1181
1182 #[test]
1183 fn version_divergent_ranges_are_unknown_not_guessed() {
1184 assert_eq!(
1187 texts(expand_braces_masked(&masked(&[("{1..9..4}", true)]), 64)),
1188 None
1189 );
1190 assert_eq!(
1191 texts(expand_braces_masked(&masked(&[("{01..03}", true)]), 64)),
1192 None
1193 );
1194 }
1195
1196 #[test]
1197 fn quoted_range_interior_is_literal() {
1198 let r = texts(expand_braces_masked(
1200 &masked(&[("{", true), ("1..3", false), ("}", true)]),
1201 64,
1202 ));
1203 assert_eq!(r, Some(vec!["{1..3}".into()]));
1204 }
1205
1206 #[test]
1207 fn fanout_cap_returns_none() {
1208 assert_eq!(
1209 texts(expand_braces_masked(&masked(&[("{1..2000}", true)]), 1024)),
1210 None
1211 );
1212 }
1213
1214 #[test]
1215 fn extreme_i64_range_no_panic_no_overflow() {
1216 let r = expand_braces_masked(
1218 &masked(&[("{-9223372036854775808..9223372036854775807}", true)]),
1219 1024,
1220 );
1221 assert!(r.is_none());
1222 }
1223
1224 #[test]
1225 fn identity_without_braces() {
1226 let r = texts(expand_braces_masked(&masked(&[("plain.txt", true)]), 64));
1227 assert_eq!(r, Some(vec!["plain.txt".into()]));
1228 }
1229}