use super::credential_print_programs::basename;
use super::credential_print_redirect::{Routed, redirect_target_sink};
use super::{Emitted, Lifted, MARK, Refusal, Sink, carries, route};
const WORD_TRIGGERS: &[&[&str]] = &[
&["gcloud", "secrets", "access"],
&["gh", "auth", "token"],
&["gh", "auth", "--show-token"],
&["gh", "auth", "-t"],
&["op", "read"],
&["aws", "configure", "get"],
&["aws", "configure", "export-credentials"],
];
const CURL_SHORT_WITH_ARG: &[char] = &[
'A', 'b', 'c', 'C', 'd', 'D', 'e', 'E', 'F', 'H', 'K', 'm', 'o', 'P', 'Q', 'r', 't', 'T', 'u',
'U', 'w', 'x', 'X', 'y', 'Y', 'z',
];
#[derive(Debug, PartialEq, Eq)]
enum Echo<'a> {
Out,
Err,
Path(&'a str),
}
const RUN_TIME_PREFIXES: &[&[&str]] = &[
&["gcloud", "secrets"],
&["gh", "auth"],
&["op"],
&["aws", "configure"],
];
pub(super) fn names_cli_trigger(flat: &str) -> bool {
let words: Vec<String> = flat
.replace(['$', '`'], " $ ")
.split(|c: char| c.is_whitespace() || ";|&()<>{}".contains(c))
.filter(|w| !w.is_empty())
.map(basename)
.collect();
let interactive = words
.iter()
.enumerate()
.any(|(at, w)| w == "security" && security_is_interactive(&words[at + 1..]));
let in_order = |seq: &[&str], then_dynamic: bool| {
let mut rest = words.iter();
seq.iter().all(|want| rest.any(|w| w == want)) && (!then_dynamic || rest.any(|w| w == "$"))
};
interactive
|| WORD_TRIGGERS.iter().any(|seq| in_order(seq, false))
|| RUN_TIME_PREFIXES.iter().any(|seq| in_order(seq, true))
}
fn security_is_interactive<S: AsRef<str>>(args: &[S]) -> bool {
for a in args {
let Some(cluster) = a
.as_ref()
.strip_prefix('-')
.filter(|c| !c.is_empty() && !c.starts_with('-'))
else {
return false;
};
if cluster.contains(['i', 'p']) {
return true;
}
}
false
}
const NON_EXECUTING_READERS: &[&str] = &[
"grep", "egrep", "fgrep", "rg", "ag", "ack", "man", "info", "apropos", "whatis", "which",
"whereis", "echo", "printf",
];
pub(super) fn interactive_security(argv: &[String], start: usize) -> bool {
let reader = argv
.get(start)
.is_some_and(|w| NON_EXECUTING_READERS.contains(&basename(w).as_str()));
!reader
&& argv.iter().enumerate().any(|(at, w)| {
basename(w) == "security"
&& security_is_interactive(argv.get(at + 1..).unwrap_or_default())
})
}
pub(super) fn judge_cli_echoes(
argv: &[String],
routed: &Routed,
lifted: &Lifted,
stage_out: Sink,
emitted: &mut Emitted,
) -> Result<(), Refusal> {
for (at, word) in argv.iter().enumerate() {
let args = argv.get(at + 1..).unwrap_or_default();
let slots = match basename(word).as_str() {
"gcloud" => 3,
"gh" | "aws" => 2,
"op" => 1,
_ => 0,
};
if subcommand_is_dynamic(args, slots) {
return Err(Refusal::Unreadable(
"a credential CLI subcommand chosen at run time",
));
}
let echoes = match basename(word).as_str() {
"curl" if args.iter().any(|a| carries(a, lifted)) => curl_echoes(args),
program => cli_echoes(program, args),
};
for echo in echoes {
let sink = match echo {
Echo::Out => routed.out,
Echo::Err => routed.err,
Echo::Path(p) => redirect_target_sink(p, &routed.fds, lifted, stage_out),
};
route(sink, emitted)?;
}
}
Ok(())
}
fn subcommand_is_dynamic(args: &[String], slots: usize) -> bool {
let mut left = slots;
for a in args {
if left == 0 {
return false;
}
if a.contains(['$', '`']) || a.contains(MARK) {
return true;
}
if a.starts_with('-') {
left += usize::from(!a.contains('='));
continue;
}
left -= 1;
}
false
}
fn cli_echoes<'a>(program: &str, args: &'a [String]) -> Vec<Echo<'a>> {
let after = |word: &str| args.iter().position(|a| a.eq_ignore_ascii_case(word));
let follows = |first: &str, then: &[&str]| {
after(first).is_some_and(|at| {
args[at + 1..]
.iter()
.any(|a| then.iter().any(|t| a.eq_ignore_ascii_case(t)))
})
};
match program {
"gcloud" if follows("secrets", &["access"]) => out_or_file(args, &["--out-file"]),
"op" if after("read").is_some() => out_or_file(args, &["--out-file", "-o"]),
"gh" if follows("auth", &["token"]) => vec![Echo::Out],
"gh" if follows("auth", &["status"]) && follows("auth", &["-t", "--show-token"]) => {
vec![Echo::Out, Echo::Err]
}
"aws" if follows("configure", &["export-credentials"]) => vec![Echo::Out],
"aws" if follows("configure", &["get"]) => {
let secret = args.iter().any(|a| {
let a = a.to_ascii_lowercase();
a.contains("secret") || a.contains("token")
});
if secret { vec![Echo::Out] } else { Vec::new() }
}
_ => Vec::new(),
}
}
fn out_or_file<'a>(args: &'a [String], flags: &[&str]) -> Vec<Echo<'a>> {
let path = args.iter().enumerate().find_map(|(n, a)| {
flags.iter().find_map(|f| {
a.strip_prefix(f)
.and_then(|rest| rest.strip_prefix('='))
.or_else(|| (a == f).then(|| args.get(n + 1).map_or("-", String::as_str)))
})
});
match path {
None | Some("-") => vec![Echo::Out],
Some(p) => vec![Echo::Path(p)],
}
}
fn curl_echoes(args: &[String]) -> Vec<Echo<'_>> {
let mut stderr_to: Option<&str> = None;
let (mut verbose, mut named) = (false, Vec::new());
let mut i = 0;
while let Some(a) = args.get(i) {
i += 1;
let value = |i: &mut usize| {
*i += 1;
args.get(*i - 1).map_or("-", String::as_str)
};
match a.as_str() {
"--" => break,
"--verbose" | "--trace-config" => verbose = true,
"--no-verbose" => verbose = false,
"--stderr" => stderr_to = Some(value(&mut i)),
"--trace" | "--trace-ascii" | "--libcurl" => named.push(value(&mut i)),
long if long.starts_with("--") => {}
short => {
let cluster = short.strip_prefix('-').unwrap_or_default();
for (at, ch) in cluster.char_indices() {
if ch == 'v' {
verbose = true;
}
if CURL_SHORT_WITH_ARG.contains(&ch) {
i += usize::from(at + ch.len_utf8() == cluster.len());
break;
}
}
}
}
}
let to_stderr = || match stderr_to {
None => Echo::Err,
Some("-") => Echo::Out,
Some(p) => Echo::Path(p),
};
let mut echoes: Vec<Echo<'_>> = named
.into_iter()
.map(|v| match v {
"-" => Echo::Out,
"%" => to_stderr(),
p => Echo::Path(p),
})
.collect();
if verbose {
echoes.push(to_stderr());
}
echoes
}