use crate::parse::Redirection;
#[derive(Debug)]
pub struct CommandContext<'a> {
pub raw: &'a str,
pub base_command: String,
pub words: Vec<String>,
pub env_vars: Vec<(String, String)>,
pub redirection: Option<Redirection>,
pub accumulated_env: std::collections::HashMap<String, String>,
}
impl<'a> CommandContext<'a> {
pub fn from_command(raw: &'a str) -> Self {
let base_command = crate::parse::base_command(raw);
let env_vars = crate::parse::env_vars(raw);
let words = crate::parse::tokenize(raw);
let redirection = crate::parse::has_output_redirection(raw);
Self {
raw,
base_command,
words,
env_vars,
redirection,
accumulated_env: std::collections::HashMap::new(),
}
}
pub fn env_satisfies(&self, required: &std::collections::HashMap<String, String>) -> bool {
required.iter().all(|(key, value)| {
let expanded = match shellexpand::full(value) {
Ok(v) => v,
Err(e) => {
log::warn!("shellexpand failed for config_env {key}={value}: {e}");
std::borrow::Cow::Borrowed(value.as_str())
}
};
if let Some((_, v)) = self.env_vars.iter().find(|(k, _)| k == key) {
return v == value || v == expanded.as_ref();
}
if let Some(v) = self.accumulated_env.get(key) {
return v == value || v == expanded.as_ref();
}
std::env::var(key).is_ok_and(|v| v == *value || v == expanded.as_ref())
})
}
pub fn args(&self) -> &[String] {
let skip = self.env_vars.len() + 1; if self.words.len() > skip {
&self.words[skip..]
} else {
&[]
}
}
pub fn has_flag(&self, flag: &str) -> bool {
self.words.iter().any(|w| w == flag)
}
pub fn has_any_flag(&self, flags: &[&str]) -> bool {
self.words.iter().any(|w| flags.contains(&w.as_str()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn require_nextest() {
assert!(
std::env::var("NEXTEST").is_ok(),
"this test mutates process env and requires nextest (cargo nextest run)"
);
}
#[test]
fn env_satisfies_inline_exact() {
let ctx = CommandContext::from_command("FOO=bar git push");
let req = HashMap::from([("FOO".into(), "bar".into())]);
assert!(ctx.env_satisfies(&req));
}
#[test]
fn env_satisfies_inline_wrong_value() {
let ctx = CommandContext::from_command("FOO=baz git push");
let req = HashMap::from([("FOO".into(), "bar".into())]);
assert!(!ctx.env_satisfies(&req));
}
#[test]
fn env_satisfies_inline_missing() {
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([("FOO".into(), "bar".into())]);
assert!(!ctx.env_satisfies(&req));
}
#[test]
fn env_satisfies_process_env() {
require_nextest();
let key = "CC_TOOLGATE_TEST_PROCESS_ENV";
unsafe { std::env::set_var(key, "expected_value") };
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([(key.into(), "expected_value".into())]);
assert!(ctx.env_satisfies(&req));
unsafe { std::env::remove_var(key) };
}
#[test]
fn env_satisfies_process_env_wrong_value() {
require_nextest();
let key = "CC_TOOLGATE_TEST_WRONG_VALUE";
unsafe { std::env::set_var(key, "actual") };
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([(key.into(), "expected".into())]);
assert!(!ctx.env_satisfies(&req));
unsafe { std::env::remove_var(key) };
}
#[test]
fn env_satisfies_multi_source_one_inline_one_process() {
require_nextest();
let key_process = "CC_TOOLGATE_TEST_MULTI_PROC";
unsafe { std::env::set_var(key_process, "/correct/path") };
let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
let req = HashMap::from([
("INLINE_VAR".into(), "correct".into()),
(key_process.into(), "/correct/path".into()),
]);
assert!(ctx.env_satisfies(&req));
unsafe { std::env::remove_var(key_process) };
}
#[test]
fn env_satisfies_multi_source_one_missing() {
let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
let req = HashMap::from([
("INLINE_VAR".into(), "correct".into()),
("MISSING_VAR".into(), "value".into()),
]);
assert!(!ctx.env_satisfies(&req));
}
#[test]
fn env_satisfies_multi_source_one_wrong() {
require_nextest();
let key_process = "CC_TOOLGATE_TEST_MULTI_WRONG";
unsafe { std::env::set_var(key_process, "/wrong/path") };
let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
let req = HashMap::from([
("INLINE_VAR".into(), "correct".into()),
(key_process.into(), "/correct/path".into()),
]);
assert!(!ctx.env_satisfies(&req));
unsafe { std::env::remove_var(key_process) };
}
#[test]
fn env_satisfies_tilde_expansion() {
require_nextest();
let key = "CC_TOOLGATE_TEST_TILDE";
let home = std::env::var("HOME").unwrap();
unsafe { std::env::set_var(key, format!("{home}/foo")) };
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([(key.into(), "~/foo".into())]);
assert!(ctx.env_satisfies(&req));
unsafe { std::env::remove_var(key) };
}
#[test]
fn env_satisfies_empty_map() {
let ctx = CommandContext::from_command("git push");
assert!(ctx.env_satisfies(&HashMap::new()));
}
const COLLISION_KEY: &str = "CC_TOOLGATE_TEST_COLLISION";
#[test]
fn env_collision_value_alpha() {
require_nextest();
unsafe { std::env::set_var(COLLISION_KEY, "alpha") };
std::thread::sleep(std::time::Duration::from_millis(5));
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([(COLLISION_KEY.into(), "alpha".into())]);
assert!(
ctx.env_satisfies(&req),
"expected 'alpha', env was tampered"
);
unsafe { std::env::remove_var(COLLISION_KEY) };
}
#[test]
fn env_collision_value_beta() {
require_nextest();
unsafe { std::env::set_var(COLLISION_KEY, "beta") };
std::thread::sleep(std::time::Duration::from_millis(5));
let ctx = CommandContext::from_command("git push");
let req = HashMap::from([(COLLISION_KEY.into(), "beta".into())]);
assert!(ctx.env_satisfies(&req), "expected 'beta', env was tampered");
unsafe { std::env::remove_var(COLLISION_KEY) };
}
}