use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ContainerConfig {
pub image: String,
pub workdir: String,
pub volumes: Vec<String>,
pub env: HashMap<String, String>,
pub network: String,
pub interactive: bool,
pub tty: bool,
pub cap_add: Vec<String>,
pub cap_drop: Vec<String>,
pub security_opt: Vec<String>,
pub pids_limit: u32,
pub auto_remove: bool,
pub read_only: bool,
pub tmpfs: Vec<String>,
}
impl ContainerConfig {
pub fn push_args(&self, args: &mut Vec<String>, command: &[String]) {
if self.auto_remove {
args.push("--rm".to_string());
}
args.push("-w".to_string());
args.push(self.workdir.clone());
args.push("--network".to_string());
args.push(self.network.clone());
for cap in &self.cap_drop {
args.push("--cap-drop".to_string());
args.push(cap.clone());
}
for cap in &self.cap_add {
args.push("--cap-add".to_string());
args.push(cap.clone());
}
for opt in &self.security_opt {
args.push("--security-opt".to_string());
args.push(opt.clone());
}
if self.pids_limit > 0 {
args.push("--pids-limit".to_string());
args.push(self.pids_limit.to_string());
}
if self.read_only {
args.push("--read-only".to_string());
}
for t in &self.tmpfs {
args.push("--tmpfs".to_string());
args.push(t.clone());
}
for v in &self.volumes {
args.push("-v".to_string());
args.push(v.clone());
}
for (k, v) in &self.env {
args.push("-e".to_string());
args.push(format!("{}={}", k, v));
}
args.push(self.image.clone());
args.extend(command.iter().cloned());
}
}
const SENSITIVE_ENV_KEYS: &[&str] = &[
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_ACCESS_KEY_ID",
"GITHUB_TOKEN",
"GH_TOKEN",
"CLOUDSDK_AUTH_ACCESS_TOKEN",
"AZURE_ACCESS_TOKEN",
];
pub(crate) fn redact_args<S: AsRef<str>>(args: &[S]) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
let s = arg.as_ref();
if redact_next {
if let Some((key, _)) = s.split_once('=') {
if SENSITIVE_ENV_KEYS.contains(&key) {
out.push(format!("{key}=***"));
} else {
out.push(s.to_owned());
}
} else {
out.push(s.to_owned());
}
redact_next = false;
} else {
out.push(s.to_owned());
redact_next = s == "-e";
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> ContainerConfig {
ContainerConfig {
image: "fedora:43".to_string(),
workdir: "/workspace".to_string(),
volumes: vec![],
env: HashMap::new(),
network: "bridge".to_string(),
interactive: true,
tty: true,
cap_add: vec![],
cap_drop: vec!["ALL".to_string()],
security_opt: vec!["no-new-privileges".to_string()],
pids_limit: 4096,
auto_remove: false,
read_only: false,
tmpfs: vec![],
}
}
#[test]
fn container_config_fields() {
let config = test_config();
assert_eq!(config.image, "fedora:43");
assert_eq!(config.cap_drop, vec!["ALL"]);
assert_eq!(config.security_opt, vec!["no-new-privileges"]);
assert_eq!(config.pids_limit, 4096);
}
#[test]
fn push_args_cap_drop_before_cap_add() {
let mut config = test_config();
config.cap_add = vec!["NET_ADMIN".to_string()];
let mut args = Vec::new();
config.push_args(&mut args, &[]);
let drop_pos = args.iter().position(|a| a == "--cap-drop").unwrap();
let add_pos = args.iter().position(|a| a == "--cap-add").unwrap();
assert!(drop_pos < add_pos, "--cap-drop must come before --cap-add");
assert!(args.contains(&"--security-opt".to_string()));
assert!(args.contains(&"no-new-privileges".to_string()));
assert!(args.contains(&"--pids-limit".to_string()));
assert!(args.contains(&"4096".to_string()));
}
#[test]
fn push_args_auto_remove() {
let mut config = test_config();
config.auto_remove = true;
let mut args = Vec::new();
config.push_args(&mut args, &["echo".to_string()]);
assert_eq!(args[0], "--rm", "--rm must be first arg when auto_remove");
config.auto_remove = false;
let mut args = Vec::new();
config.push_args(&mut args, &[]);
assert!(!args.contains(&"--rm".to_string()));
}
#[test]
fn push_args_read_only_with_tmpfs() {
let mut config = test_config();
config.read_only = true;
config.tmpfs = vec!["/tmp".to_string(), "/run".to_string()];
let mut args = Vec::new();
config.push_args(&mut args, &[]);
assert!(args.contains(&"--read-only".to_string()));
let tmpfs_positions: Vec<usize> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "--tmpfs")
.map(|(i, _)| i)
.collect();
assert_eq!(tmpfs_positions.len(), 2);
assert_eq!(args[tmpfs_positions[0] + 1], "/tmp");
assert_eq!(args[tmpfs_positions[1] + 1], "/run");
}
#[test]
fn push_args_no_read_only_by_default() {
let config = test_config();
let mut args = Vec::new();
config.push_args(&mut args, &[]);
assert!(!args.contains(&"--read-only".to_string()));
assert!(!args.contains(&"--tmpfs".to_string()));
}
#[test]
fn redact_args_masks_sensitive_keys() {
let args: Vec<String> = vec![
"run",
"-d",
"-e",
"AWS_SECRET_ACCESS_KEY=hunter2",
"-e",
"GITHUB_TOKEN=ghp_abc123",
"-e",
"PATH=/usr/bin",
"fedora:43",
]
.into_iter()
.map(String::from)
.collect();
let redacted = redact_args(&args);
assert_eq!(redacted[3], "AWS_SECRET_ACCESS_KEY=***");
assert_eq!(redacted[5], "GITHUB_TOKEN=***");
assert_eq!(redacted[7], "PATH=/usr/bin");
}
#[test]
fn redact_args_preserves_non_sensitive() {
let args: Vec<String> = vec![
"run",
"-e",
"HOME=/home/dev",
"-e",
"LANG=en_US.UTF-8",
"-w",
"/workspace",
]
.into_iter()
.map(String::from)
.collect();
let redacted = redact_args(&args);
assert_eq!(redacted, args);
}
#[test]
fn redact_args_handles_no_env() {
let args: Vec<String> = vec!["run", "-d", "-w", "/workspace", "fedora:43"]
.into_iter()
.map(String::from)
.collect();
let redacted = redact_args(&args);
assert_eq!(redacted, args);
}
#[test]
fn redact_args_works_with_str_slices() {
let args: &[&str] = &[
"run",
"-d",
"-e",
"AWS_SESSION_TOKEN=secret123",
"-e",
"HOME=/home/dev",
];
let redacted = redact_args(args);
assert_eq!(redacted[3], "AWS_SESSION_TOKEN=***");
assert_eq!(redacted[5], "HOME=/home/dev");
}
#[test]
fn redact_args_trailing_dash_e_no_panic() {
let args: Vec<String> = vec!["run", "-e"].into_iter().map(String::from).collect();
let redacted = redact_args(&args);
assert_eq!(redacted, args);
}
#[test]
fn push_args_no_pids_limit_when_zero() {
let mut config = test_config();
config.pids_limit = 0;
config.cap_drop = vec![];
config.security_opt = vec![];
let mut args = Vec::new();
config.push_args(&mut args, &[]);
assert!(!args.contains(&"--pids-limit".to_string()));
}
}