use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use regex::{Regex, RegexSet};
use crate::config::{
Config, Guards, NamedPattern, default_confirm_patterns, default_deny_patterns,
};
use crate::errors::{Result, SshError};
#[derive(Debug, Clone)]
pub struct CompiledPattern {
pub name: String,
pub re: Regex,
}
#[derive(Debug, Clone)]
pub struct PatternBank {
patterns: Vec<CompiledPattern>,
set: RegexSet,
}
impl PatternBank {
fn new(patterns: Vec<CompiledPattern>) -> Result<Self> {
let set = RegexSet::new(patterns.iter().map(|p| p.re.as_str()))
.map_err(|e| SshError::Config(format!("regex set compile failed: {e}")))?;
Ok(Self { patterns, set })
}
fn matched(&self, cmd: &str) -> Option<&CompiledPattern> {
if self.patterns.is_empty() {
return None;
}
let m = self.set.matches(cmd);
if !m.matched_any() {
return None;
}
let first = m.iter().next()?;
self.patterns.get(first)
}
}
#[derive(Debug, Clone)]
pub struct CompiledGuards {
pub deny: PatternBank,
pub confirm: PatternBank,
pub read_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardCheck {
Allow,
Confirm {
pattern_name: String,
},
Deny {
pattern_name: String,
pattern: String,
},
}
impl CompiledGuards {
pub fn compile(g: &Guards) -> Result<Self> {
let mut deny = Vec::new();
let mut confirm = Vec::new();
if g.use_default_deny {
for p in default_deny_patterns() {
deny.push(compile_one(&p)?);
}
}
if g.use_default_confirm {
for p in default_confirm_patterns() {
confirm.push(compile_one(&p)?);
}
}
for p in &g.deny {
deny.push(compile_one(p)?);
}
for p in &g.confirm {
confirm.push(compile_one(p)?);
}
Ok(Self {
deny: PatternBank::new(deny)?,
confirm: PatternBank::new(confirm)?,
read_only: g.read_only,
})
}
pub fn check(&self, cmd: &str) -> GuardCheck {
if let Some(p) = self.deny.matched(cmd) {
return GuardCheck::Deny {
pattern_name: p.name.clone(),
pattern: p.re.as_str().to_string(),
};
}
if self.read_only && looks_writeful(cmd) {
return GuardCheck::Deny {
pattern_name: "read-only".into(),
pattern: "host marked read_only".into(),
};
}
if let Some(p) = self.confirm.matched(cmd) {
return GuardCheck::Confirm {
pattern_name: p.name.clone(),
};
}
GuardCheck::Allow
}
pub fn check_sftp_write(&self, remote_path: &str) -> Result<()> {
if self.read_only {
return Err(SshError::BlockedByGuard {
name: "read-only".into(),
pattern: "host marked read_only".into(),
});
}
if sensitive_write_path_re().is_match(remote_path) {
return Err(SshError::BlockedByGuard {
name: "sensitive-path".into(),
pattern: "write to sensitive system path blocked".into(),
});
}
Ok(())
}
pub fn check_sftp_read(&self, remote_path: &str) -> Result<()> {
if sensitive_read_path_re().is_match(remote_path) {
return Err(SshError::BlockedByGuard {
name: "sensitive-read".into(),
pattern: "read of sensitive system path blocked".into(),
});
}
Ok(())
}
}
fn sensitive_read_path_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?ix)
(?:^|/)
(?:
shadow | gshadow
| sudoers
| id_(?:rsa|ed25519|ecdsa|dsa|sk)
| identity
)
$
|
(?:^|/)\.ssh/id_[a-z0-9_]+$
|
(?:^|/)\.aws/credentials$
|
(?:^|/)\.kube/config$
|
(?:^|/)\.docker/config\.json$
|
(?:^|/)\.config/gcloud/.+$
|
(?:^|/)\.azure/.+$
|
(?:^|/)\.git-credentials$
|
(?:^|/)\.netrc$
|
(?:^|/)\.pgpass$
|
(?:^|/)etc/(?:shadow|gshadow|sudoers)$
|
(?:^|/)etc/sudoers\.d/.+$
|
(?:^|/)etc/ssh/ssh_host_[a-z0-9_]+_key$
|
(?:^|/)proc/[0-9]+/(?:mem|environ)$
"#,
)
.expect("sensitive_read_path_re valid")
})
}
fn sensitive_write_path_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?ix)
(?:^|/)
(?:
# authorized_keys2 is still in sshd's default AuthorizedKeysFile,
# and ~/.ssh/rc is executed by sshd on every login — both were
# reachable past the old `authorized_keys|known_hosts|id_*` list.
\.ssh/(?:authorized_keys2?|known_hosts2?|id_[a-z0-9]+|rc|config|environment)
| sudoers
| shadow | gshadow | passwd | group
| crontab
)
$
|
(?:^|/)
(?:
etc/(?:sudoers\.d|cron\.(?:d|hourly|daily|weekly|monthly)|init\.d|systemd/system|pam\.d|ssh|profile\.d)/.*
| etc/(?:fstab|hosts|resolv\.conf|nsswitch\.conf|environment|profile|ld\.so\.preload|ld\.so\.conf)
| etc/ld\.so\.conf\.d/.*
| (?:usr/)?lib/systemd/(?:system|user)/.*
| var/spool/cron/.*
| root/\.ssh/.*
| boot/.*
)
$"#,
)
.expect("sensitive_write_path_re valid")
})
}
pub fn resolve_local_path(raw: &str) -> PathBuf {
let mut p = PathBuf::from(shellexpand::tilde(raw).into_owned());
if p.is_relative()
&& let Ok(cwd) = std::env::current_dir()
{
p = cwd.join(p);
}
if let (Some(parent), Some(name)) = (p.parent(), p.file_name())
&& let Ok(real) = parent.canonicalize()
{
return lexical_normalize(&real.join(name));
}
lexical_normalize(&p)
}
pub fn check_local_write(resolved: &Path) -> Result<()> {
let s = slashed(resolved);
if local_write_path_re().is_match(&s) || is_home_dotrc(resolved) {
return Err(SshError::BlockedByGuard {
name: "local-write".into(),
pattern: "write to sensitive local path blocked".into(),
});
}
Ok(())
}
pub fn check_local_read(resolved: &Path) -> Result<()> {
if local_read_path_re().is_match(&slashed(resolved)) {
return Err(SshError::BlockedByGuard {
name: "local-read".into(),
pattern: "read of sensitive local path blocked".into(),
});
}
Ok(())
}
fn local_write_path_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?ix)
# Shell startup files — sourced by the next interactive shell.
(?:^|/)\.(?:bashrc|bash_profile|bash_login|bash_logout|profile
|zshrc|zshenv|zprofile|zlogin|zlogout|kshrc|cshrc|tcshrc)$
|
(?:^|/)\.config/fish/(?:config\.fish|conf\.d/.+|functions/.+)$
|
# Anything under .ssh/ — keys, config (ProxyCommand), known_hosts.
(?:^|/)\.ssh/.+$
|
(?:^|/)\.(?:gitconfig|npmrc|pypirc|netrc|pgpass|curlrc|wgetrc)$
|
(?:^|/)\.aws/.+$
|
(?:^|/)\.config/gcloud/.+$
|
(?:^|/)\.kube/.+$
|
(?:^|/)\.docker/.+$
|
# Crontab spools, both Debian and RedHat layouts.
(?:^|/)(?:var/spool/cron|etc/cron\.d|etc/cron\.(?:hourly|daily|weekly|monthly))/.+$
|
(?:^|/)\.config/(?:autostart|systemd/user)/.+$
|
# Windows autostart: a dropped file runs at next logon.
# `\x20`, not a literal space: `(?x)` strips whitespace inside
# character classes too, so `[ ]` would parse as an unclosed class.
(?:^|/)start\x20menu/programs/startup/.+$
"#,
)
.expect("local_write_path_re valid")
})
}
fn local_read_path_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?ix)
# Private keys. The char class excludes `.` so `id_rsa.pub` — which
# is meant to be copied around — stays readable.
(?:^|/)\.ssh/id_[a-z0-9_-]+$
|
\.(?:pem|key|pfx|p12)$
|
(?:^|/)\.aws/credentials$
|
(?:^|/)\.config/gcloud/.+$
|
(?:^|/)\.kube/config$
|
(?:^|/)\.docker/config\.json$
|
(?:^|/)\.(?:netrc|npmrc|pypirc|pgpass|git-credentials)$
|
# Browser cookie / saved-password stores: session-token theft.
(?:^|/)(?:cookies\.sqlite|cookies|login\x20data|key[34]\.db|logins\.json)$
|
(?:^|/)\.env(?:\.[a-z0-9_.-]+)?$
"#,
)
.expect("local_read_path_re valid")
})
}
fn is_home_dotrc(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.len() < 4 || !name.starts_with('.') || !name.to_ascii_lowercase().ends_with("rc") {
return false;
}
let home = shellexpand::tilde("~");
if home.as_ref() == "~" {
return false;
}
let h = slashed(Path::new(home.as_ref()))
.trim_end_matches('/')
.to_ascii_lowercase();
if h.is_empty() {
return false;
}
let p = slashed(path).to_ascii_lowercase();
p.len() > h.len() && p.starts_with(&h) && p.as_bytes().get(h.len()) == Some(&b'/')
}
fn slashed(p: &Path) -> String {
let s = p.to_string_lossy().replace('\\', "/");
match s.strip_prefix("//?/") {
Some(rest) => rest.to_string(),
None => s,
}
}
fn lexical_normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Debug, Clone)]
pub struct GuardCache {
default: Arc<CompiledGuards>,
by_host: HashMap<String, Arc<CompiledGuards>>,
}
impl GuardCache {
pub fn build(cfg: &Config) -> Result<Self> {
let default = Arc::new(CompiledGuards::compile(&cfg.defaults.guards)?);
let mut by_host = HashMap::with_capacity(cfg.hosts.len());
for (name, host) in &cfg.hosts {
if let Some(g) = &host.guards {
by_host.insert(name.clone(), Arc::new(CompiledGuards::compile(g)?));
}
}
Ok(Self { default, by_host })
}
pub fn for_host(&self, host: &str) -> Arc<CompiledGuards> {
self.by_host
.get(host)
.cloned()
.unwrap_or_else(|| Arc::clone(&self.default))
}
}
fn compile_one(p: &NamedPattern) -> Result<CompiledPattern> {
let re = Regex::new(&p.pattern)
.map_err(|e| SshError::Config(format!("bad regex in guard '{}': {e}", p.name)))?;
Ok(CompiledPattern {
name: p.name.clone(),
re,
})
}
const ALWAYS_WRITE: &[&str] = &[
"rm",
"mv",
"cp",
"mkdir",
"rmdir",
"chmod",
"chown",
"ln",
"touch",
"dd",
"mkfs",
"shred",
"fallocate",
"truncate",
"tee",
"sponge",
"reboot",
"shutdown",
"halt",
"poweroff",
];
const SUBCOMMAND_WRITE: &[(&str, &str)] = &[
("systemctl", "restart"),
("systemctl", "stop"),
("systemctl", "start"),
("systemctl", "enable"),
("systemctl", "disable"),
("systemctl", "mask"),
("systemctl", "unmask"),
("systemctl", "reload"),
("service", "restart"),
("service", "stop"),
("service", "start"),
("docker", "run"),
("docker", "rm"),
("docker", "rmi"),
("docker", "stop"),
("docker", "start"),
("docker", "restart"),
("docker", "kill"),
("docker", "exec"),
("docker", "compose"),
("docker", "build"),
("docker", "pull"),
("docker", "push"),
("apt", "install"),
("apt", "upgrade"),
("apt", "remove"),
("apt", "purge"),
("apt", "autoremove"),
("apt-get", "install"),
("apt-get", "upgrade"),
("apt-get", "remove"),
("apt-get", "purge"),
("yum", "install"),
("yum", "remove"),
("yum", "update"),
("dnf", "install"),
("dnf", "remove"),
("dnf", "update"),
("pacman", "-S"),
("pacman", "-R"),
("pacman", "-U"),
("pacman", "-Syu"),
("pip", "install"),
("pip", "uninstall"),
("pip3", "install"),
("pip3", "uninstall"),
("npm", "install"),
("npm", "i"),
("npm", "uninstall"),
("yarn", "add"),
("yarn", "remove"),
("pnpm", "add"),
("pnpm", "remove"),
("git", "push"),
("git", "reset"),
("git", "checkout"),
("git", "rebase"),
("git", "merge"),
("git", "pull"),
("git", "commit"),
("git", "clean"),
];
fn looks_writeful(cmd: &str) -> bool {
let segs = parse_segments(cmd);
for seg in segs {
if seg.has_redirect {
return true;
}
let Some(first) = &seg.first else { continue };
let lc1 = first.to_ascii_lowercase();
if ALWAYS_WRITE.contains(&lc1.as_str()) {
return true;
}
if let Some(second) = &seg.second {
let lc2 = second.to_ascii_lowercase();
if SUBCOMMAND_WRITE
.iter()
.any(|(c, sub)| *c == lc1 && *sub == lc2)
{
return true;
}
}
}
false
}
#[derive(Default, Debug)]
struct Segment {
first: Option<String>,
second: Option<String>,
has_redirect: bool,
}
fn parse_segments(cmd: &str) -> Vec<Segment> {
let mut out = Vec::new();
let mut cur = Segment::default();
let mut buf = String::new();
let mut tokens_seen = 0usize;
let mut in_single = false;
let mut in_double = false;
let mut iter = cmd.chars().peekable();
let push_token = |buf: &mut String, cur: &mut Segment, tokens_seen: &mut usize| {
if buf.is_empty() {
return;
}
match *tokens_seen {
0 => cur.first = Some(std::mem::take(buf)),
1 => cur.second = Some(std::mem::take(buf)),
_ => buf.clear(),
}
*tokens_seen += 1;
};
let push_segment =
|out: &mut Vec<Segment>, cur: &mut Segment, buf: &mut String, tokens_seen: &mut usize| {
push_token(buf, cur, tokens_seen);
out.push(std::mem::take(cur));
*tokens_seen = 0;
};
while let Some(c) = iter.next() {
if in_single {
if c == '\'' {
in_single = false;
} else {
buf.push(c);
}
continue;
}
if in_double {
if c == '"' {
in_double = false;
} else if c == '\\' {
if let Some(&next) = iter.peek()
&& matches!(next, '"' | '\\' | '$' | '`' | '\n')
{
buf.push(iter.next().unwrap());
continue;
}
buf.push(c);
} else {
buf.push(c);
}
continue;
}
match c {
'\'' => in_single = true,
'"' => in_double = true,
'\\' => {
if let Some(next) = iter.next() {
buf.push(next);
}
}
'|' => {
push_segment(&mut out, &mut cur, &mut buf, &mut tokens_seen);
if matches!(iter.peek(), Some('|')) {
iter.next();
}
}
'&' => {
if matches!(iter.peek(), Some('&')) {
iter.next();
push_segment(&mut out, &mut cur, &mut buf, &mut tokens_seen);
}
else {
push_segment(&mut out, &mut cur, &mut buf, &mut tokens_seen);
}
}
';' | '\n' => {
push_segment(&mut out, &mut cur, &mut buf, &mut tokens_seen);
}
'>' | '<' => {
cur.has_redirect = true;
push_token(&mut buf, &mut cur, &mut tokens_seen);
if matches!(iter.peek(), Some('>') | Some('<') | Some('&')) {
iter.next();
}
}
c if c.is_whitespace() => {
push_token(&mut buf, &mut cur, &mut tokens_seen);
}
_ => {
buf.push(c);
}
}
}
push_segment(&mut out, &mut cur, &mut buf, &mut tokens_seen);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deny_rm_rf_root() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
assert!(matches!(g.check("rm -rf /"), GuardCheck::Deny { .. }));
assert!(matches!(g.check("rm -rf /usr"), GuardCheck::Deny { .. }));
assert!(matches!(g.check("rm -rf ./tmp"), GuardCheck::Allow));
}
#[test]
fn deny_rm_rf_root_bypass_attempts() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for cmd in [
"rm -rf '/'",
"rm -rf \"/\"",
"rm -rf //",
"rm -rf /*",
"rm -rf -- /",
"RM -rf /",
"rm --recursive --force /",
"rm -fr /",
"rm -Rf /",
] {
assert!(
matches!(g.check(cmd), GuardCheck::Deny { .. }),
"should deny: {cmd:?}"
);
}
for cmd in [
"rm ./tmp",
"rm -rf ~/tmp",
"rm foo/bar",
"ls /",
"rm -f /tmp/t.log",
"rm -rf /tmp/bench-mkdir",
"rm -rf /home/user/project/target",
] {
assert!(
!matches!(g.check(cmd), GuardCheck::Deny { .. }),
"should allow: {cmd:?}"
);
}
assert!(matches!(g.check("rm -rf /etc/"), GuardCheck::Deny { .. }));
}
#[test]
fn confirm_shutdown() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
match g.check("sudo shutdown -h now") {
GuardCheck::Confirm { pattern_name } => assert_eq!(pattern_name, "shutdown"),
other => panic!("expected confirm, got {other:?}"),
}
}
#[test]
fn read_only_blocks_write() {
let gc = Guards {
read_only: true,
..Default::default()
};
let g = CompiledGuards::compile(&gc).unwrap();
assert!(matches!(g.check("rm /tmp/foo"), GuardCheck::Deny { .. }));
assert!(matches!(g.check("ls /tmp"), GuardCheck::Allow));
}
#[test]
fn read_only_no_substring_false_positives() {
let gc = Guards {
read_only: true,
..Default::default()
};
let g = CompiledGuards::compile(&gc).unwrap();
for cmd in [
"echo 'rm '",
"echo \"rm test\"",
"grep 'mv foo' /var/log/syslog",
"ls -la 'has > sign'",
"cat /tmp/firmware.bin",
] {
assert!(
!matches!(g.check(cmd), GuardCheck::Deny { .. }),
"should allow: {cmd:?}"
);
}
}
#[test]
fn read_only_blocks_no_space_redirects() {
let gc = Guards {
read_only: true,
..Default::default()
};
let g = CompiledGuards::compile(&gc).unwrap();
for cmd in [
"echo hi>file",
"echo hi >file",
"echo hi> file",
"tail -f log >> out",
] {
assert!(
matches!(g.check(cmd), GuardCheck::Deny { .. }),
"should deny: {cmd:?}"
);
}
}
#[test]
fn read_only_pipeline_segments() {
let gc = Guards {
read_only: true,
..Default::default()
};
let g = CompiledGuards::compile(&gc).unwrap();
assert!(matches!(
g.check("ls /tmp | tee out"),
GuardCheck::Deny { .. }
));
assert!(!matches!(
g.check("cat /etc/hostname | head -c 16"),
GuardCheck::Deny { .. }
));
}
#[test]
fn dd_disk_blocked() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
assert!(matches!(
g.check("dd if=/dev/zero of=/dev/sda"),
GuardCheck::Deny { .. }
));
}
#[test]
fn forkbomb_blocked() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
assert!(matches!(g.check(":(){ :|:& };:"), GuardCheck::Deny { .. }));
}
#[test]
fn allow_simple_ls() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
assert_eq!(g.check("ls -la /etc"), GuardCheck::Allow);
}
#[test]
fn sftp_read_blocks_sensitive_paths() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for path in [
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d/01_users",
"/root/.ssh/id_rsa",
"/home/alice/.ssh/id_ed25519",
"/home/alice/.aws/credentials",
"/home/alice/.kube/config",
"/home/alice/.docker/config.json",
"/home/alice/.config/gcloud/credentials.json",
"/home/alice/.netrc",
"/home/alice/.pgpass",
"/etc/ssh/ssh_host_rsa_key",
"/proc/123/environ",
] {
assert!(g.check_sftp_read(path).is_err(), "should block: {path}");
}
}
#[test]
fn sftp_read_allows_safe_paths() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for path in [
"/etc/hostname",
"/var/log/syslog",
"/home/alice/.ssh/authorized_keys",
"/home/alice/.ssh/id_rsa.pub",
"/home/alice/notes.txt",
] {
assert!(g.check_sftp_read(path).is_ok(), "should allow: {path}");
}
}
#[test]
fn sftp_read_blocks_added_paths() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for path in [
"/proc/1/environ",
"/etc/sudoers",
"/home/alice/.config/gcloud/access_tokens.db",
"/home/alice/.azure/msal_token_cache.json",
"/home/alice/.git-credentials",
] {
assert!(g.check_sftp_read(path).is_err(), "should block: {path}");
}
for path in ["/home/alice/.config/nvim/init.lua", "/proc/cpuinfo"] {
assert!(g.check_sftp_read(path).is_ok(), "should allow: {path}");
}
}
#[test]
fn sftp_write_blocks_sensitive_paths() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for path in [
"/home/alice/.ssh/authorized_keys2",
"/home/alice/.ssh/rc",
"/home/alice/.ssh/config",
"/etc/ld.so.preload",
"/etc/ld.so.conf.d/local.conf",
"/var/spool/cron/crontabs/root",
"/etc/cron.d/backup",
"/etc/sudoers",
"/etc/sudoers.d/90-cloud",
"/lib/systemd/system/ssh.service",
"/usr/lib/systemd/system/ssh.service",
"/etc/systemd/system/evil.service",
"/etc/profile.d/evil.sh",
"/etc/pam.d/sshd",
"/root/.ssh/authorized_keys",
] {
assert!(g.check_sftp_write(path).is_err(), "should block: {path}");
}
}
#[test]
fn sftp_write_allows_safe_paths() {
let g = CompiledGuards::compile(&Guards::default()).unwrap();
for path in [
"/tmp/deploy.sh",
"/home/alice/project/src/main.rs",
"/var/spooled/notes.txt",
"/opt/app/config.yaml",
"/home/alice/.ssh_backup_notes",
] {
assert!(g.check_sftp_write(path).is_ok(), "should allow: {path}");
}
}
#[test]
fn local_write_blocks_startup_and_credential_paths() {
for path in [
"/home/alice/.bashrc",
"/home/alice/.bash_profile",
"/home/alice/.profile",
"/home/alice/.zshrc",
"/home/alice/.zshenv",
"/home/alice/.zprofile",
"/home/alice/.config/fish/config.fish",
"/home/alice/.ssh/authorized_keys",
"/home/alice/.ssh/config",
"/home/alice/.gitconfig",
"/home/alice/.npmrc",
"/home/alice/.pypirc",
"/home/alice/.netrc",
"/home/alice/.aws/credentials",
"/home/alice/.config/gcloud/settings.json",
"/home/alice/.kube/config",
"/home/alice/.docker/config.json",
"/var/spool/cron/crontabs/alice",
"/etc/cron.d/job",
"C:/Users/alice/AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup/x.bat",
] {
assert!(
check_local_write(Path::new(path)).is_err(),
"should block: {path}"
);
}
}
#[test]
fn local_write_allows_ordinary_paths() {
for path in [
"/home/alice/downloads/report.pdf",
"/tmp/out.log",
"/home/alice/project/.env.example.txt",
"C:/Users/alice/Downloads/build.zip",
] {
assert!(
check_local_write(Path::new(path)).is_ok(),
"should allow: {path}"
);
}
}
#[test]
fn local_write_blocks_parent_traversal() {
let raw = "~/fast-mcp-ssh-no-such-dir/../.bashrc";
let resolved = resolve_local_path(raw);
assert!(
check_local_write(&resolved).is_err(),
"traversal should still hit the guard: {}",
resolved.display()
);
}
#[test]
fn local_write_blocks_home_dotrc_catchall() {
let home = PathBuf::from(shellexpand::tilde("~").into_owned());
assert!(check_local_write(&home.join(".vimrc")).is_err());
assert!(check_local_write(&home.join(".inputrc")).is_err());
assert!(check_local_write(Path::new("/tmp/scratch/.vimrc")).is_ok());
assert!(check_local_write(&home.join(".vimrc.bak")).is_ok());
}
#[test]
fn local_read_blocks_secrets() {
for path in [
"/home/alice/.ssh/id_rsa",
"/home/alice/.ssh/id_ed25519",
"/home/alice/keys/server.pem",
"/home/alice/keys/server.key",
"/home/alice/.aws/credentials",
"/home/alice/.config/gcloud/credentials.db",
"/home/alice/.kube/config",
"/home/alice/.docker/config.json",
"/home/alice/.netrc",
"/home/alice/.npmrc",
"/home/alice/.pypirc",
"/home/alice/.mozilla/firefox/p/cookies.sqlite",
"C:/Users/alice/AppData/Local/Google/Chrome/User Data/Default/Login Data",
"C:/Users/alice/AppData/Local/Google/Chrome/User Data/Default/Cookies",
"/home/alice/app/.env",
"/home/alice/app/.env.production",
] {
assert!(
check_local_read(Path::new(path)).is_err(),
"should block: {path}"
);
}
}
#[test]
fn local_read_allows_ordinary_paths() {
for path in [
"/home/alice/.ssh/id_rsa.pub",
"/home/alice/.ssh/known_hosts",
"/home/alice/project/main.rs",
"/home/alice/project/env.example",
"/tmp/build.tar.gz",
] {
assert!(
check_local_read(Path::new(path)).is_ok(),
"should allow: {path}"
);
}
}
#[test]
fn lexical_normalize_drops_traversal() {
assert_eq!(
slashed(&lexical_normalize(Path::new("/a/b/../c/./d"))),
"/a/c/d"
);
assert_eq!(slashed(&lexical_normalize(Path::new("/../../etc"))), "/etc");
}
}