use crate::violations::{Violation, ViolationKind};
const DENIAL_MARKERS: &[&str] = &[
"Operation not permitted",
"Permission denied",
"Read-only file system",
];
#[must_use]
pub fn parse_stderr(stderr: &str, command: Option<&str>) -> Vec<Violation> {
stderr
.lines()
.filter_map(|line| parse_line(line, command))
.collect()
}
fn parse_line(line: &str, command: Option<&str>) -> Option<Violation> {
if !DENIAL_MARKERS.iter().any(|m| line.contains(m)) {
return None;
}
Some(Violation::new(
infer_kind_from_line(line),
extract_path(line),
command.map(str::to_string),
))
}
fn extract_path(line: &str) -> Option<String> {
line.split_whitespace()
.rev()
.map(|tok| tok.trim_matches(|c: char| matches!(c, ':' | ',' | '\'' | '"' | '`')))
.find(|tok| tok.starts_with('/') && tok.len() > 1)
.map(str::to_string)
}
fn infer_kind_from_line(line: &str) -> ViolationKind {
let lower = line.to_ascii_lowercase();
if lower.contains("read-only file system") {
return ViolationKind::FileWrite;
}
let write_markers = [
"touch",
"mkdir",
"cannot create",
"cannot remove",
"cannot move",
"cannot copy",
"rm:",
"mv:",
"rmdir:",
"chmod:",
"chown:",
];
if write_markers.iter().any(|m| lower.contains(m)) {
return ViolationKind::FileWrite;
}
let read_markers = ["cat:", "ls:", "head:", "tail:", "find:", "grep:"];
if read_markers.iter().any(|m| lower.contains(m)) {
return ViolationKind::FileRead;
}
if lower.starts_with("sh:") || lower.starts_with("bash:") {
return ViolationKind::FileWrite;
}
ViolationKind::Other
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn touch_denial_classified_as_write() {
let stderr = "touch: /Users/me/.ssh/canary: Operation not permitted\n";
let v = parse_stderr(stderr, Some("touch ~/.ssh/canary"));
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileWrite);
assert_eq!(v[0].target.as_deref(), Some("/Users/me/.ssh/canary"));
assert_eq!(v[0].command.as_deref(), Some("touch ~/.ssh/canary"));
}
#[test]
fn shell_redirect_denial_classified_as_write() {
let stderr = "sh: line 1: /var/folders/xyz/evil.txt: Operation not permitted\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileWrite);
assert_eq!(v[0].target.as_deref(), Some("/var/folders/xyz/evil.txt"));
}
#[test]
fn ls_denial_classified_as_read() {
let stderr = "ls: /Users/me/.config/koda/db: Operation not permitted\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileRead);
assert_eq!(v[0].target.as_deref(), Some("/Users/me/.config/koda/db"));
}
#[test]
fn cat_denial_classified_as_read() {
let stderr = "cat: /etc/shadow: Permission denied\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileRead);
assert_eq!(v[0].target.as_deref(), Some("/etc/shadow"));
}
#[test]
fn cp_cannot_create_classified_as_write() {
let stderr = "cp: cannot create regular file '/etc/passwd': Permission denied\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileWrite);
assert_eq!(v[0].target.as_deref(), Some("/etc/passwd"));
}
#[test]
fn read_only_file_system_classified_as_write() {
let stderr = "touch: /usr/bin/foo: Read-only file system\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileWrite);
}
#[test]
fn lines_without_denial_markers_are_ignored() {
let stderr = "warning: deprecated flag\nINFO: built target koda-cli\n";
let v = parse_stderr(stderr, None);
assert!(v.is_empty());
}
#[test]
fn multiple_denials_yield_multiple_violations() {
let stderr = "\
touch: /etc/passwd: Operation not permitted
touch: /etc/shadow: Operation not permitted
";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 2);
assert_eq!(v[0].target.as_deref(), Some("/etc/passwd"));
assert_eq!(v[1].target.as_deref(), Some("/etc/shadow"));
}
#[test]
fn empty_stderr_yields_no_violations() {
assert!(parse_stderr("", None).is_empty());
}
#[test]
fn denial_without_path_still_recorded_as_other() {
let stderr = "chmod: changing permissions: Operation not permitted\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, ViolationKind::FileWrite);
assert!(v[0].target.is_none(), "no abs path → no target");
}
#[test]
fn command_is_attached_to_every_violation() {
let stderr = "touch: /a: Operation not permitted\ntouch: /b: Operation not permitted\n";
let v = parse_stderr(stderr, Some("my-script.sh"));
assert_eq!(v.len(), 2);
assert!(
v.iter()
.all(|x| x.command.as_deref() == Some("my-script.sh"))
);
}
#[test]
fn extracts_last_absolute_path_when_multiple_present() {
let stderr = "denied write to /home/me/secret (allowed: /tmp): Operation not permitted\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert!(v[0].target.is_some());
}
#[test]
fn ignores_relative_paths() {
let stderr = "touch: foo.txt: Permission denied\n";
let v = parse_stderr(stderr, None);
assert_eq!(v.len(), 1);
assert!(
v[0].target.is_none(),
"relative path must not be attributed"
);
}
}