Skip to main content

loopflow/
security.rs

1use std::path::Path;
2
3pub fn sanitize_fs_component(value: &str) -> String {
4    let mut sanitized = String::new();
5    let mut pending_dash = false;
6
7    for character in value.chars() {
8        if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
9            if pending_dash && !sanitized.is_empty() {
10                sanitized.push('-');
11            }
12            pending_dash = false;
13            sanitized.push(character);
14        } else {
15            pending_dash = true;
16        }
17    }
18
19    if sanitized.is_empty() {
20        "wave".to_string()
21    } else {
22        sanitized
23    }
24}
25
26/// Redact operator-visible error/status text.
27pub fn sanitize_operator_message(message: &str) -> String {
28    let mut sanitized = redact_known_paths(message);
29    sanitized = redact_bearer_token_segments(&sanitized);
30    sanitized = redact_long_secret_segments(&sanitized);
31    sanitized = redact_internal_identifiers(&sanitized);
32    sanitized
33}
34
35fn redact_known_paths(message: &str) -> String {
36    let mut sanitized = message.to_string();
37    if let Some(home) = dirs::home_dir() {
38        let home = home.to_string_lossy().to_string();
39        if !home.is_empty() {
40            sanitized = sanitized.replace(&home, "[REDACTED_PATH]");
41        }
42    }
43    if let Ok(cwd) = std::env::current_dir() {
44        let cwd = cwd.to_string_lossy().to_string();
45        if !cwd.is_empty() {
46            sanitized = sanitized.replace(&cwd, "[REDACTED_PATH]");
47        }
48    }
49
50    for token in extract_absolute_path_tokens(message) {
51        sanitized = sanitized.replace(&token, "[REDACTED_PATH]");
52    }
53    sanitized
54}
55
56fn redact_bearer_token_segments(message: &str) -> String {
57    let mut result = String::new();
58    let mut remaining = message;
59    while let Some(idx) = remaining.find("Bearer ") {
60        result.push_str(&remaining[..idx]);
61        let after = &remaining[idx + "Bearer ".len()..];
62        let token = after.split_whitespace().next().unwrap_or_default();
63        if token.is_empty() {
64            result.push_str("Bearer");
65            remaining = after;
66        } else {
67            result.push_str("Bearer [REDACTED_TOKEN]");
68            remaining = &after[token.len()..];
69        }
70    }
71    result.push_str(remaining);
72    result
73}
74
75fn redact_long_secret_segments(message: &str) -> String {
76    message
77        .split_whitespace()
78        .map(redact_word_if_secret)
79        .collect::<Vec<_>>()
80        .join(" ")
81}
82
83fn redact_word_if_secret(word: &str) -> String {
84    let trimmed = word.trim_matches(|ch: char| ch == '"' || ch == '\'' || ch == ',');
85    let looks_secret = trimmed.len() >= 24
86        && trimmed.chars().any(|ch| ch.is_ascii_alphabetic())
87        && trimmed.chars().any(|ch| ch.is_ascii_digit())
88        && trimmed
89            .chars()
90            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.');
91    if looks_secret {
92        word.replace(trimmed, "[REDACTED_TOKEN]")
93    } else {
94        word.to_string()
95    }
96}
97
98fn redact_internal_identifiers(message: &str) -> String {
99    let mut sanitized = message.to_string();
100    for marker in [
101        "docker://",
102        "/var/lib/docker/volumes/",
103        "volume ",
104        "container ",
105    ] {
106        if sanitized.contains(marker) {
107            sanitized = sanitized.replace(marker, "[REDACTED_INTERNAL] ");
108        }
109    }
110    sanitized
111}
112
113fn extract_absolute_path_tokens(message: &str) -> Vec<String> {
114    message
115        .split_whitespace()
116        .map(|word| word.trim_matches(|ch: char| ch == '"' || ch == '\'' || ch == ','))
117        .filter(|word| is_absolute_path_token(word))
118        .map(str::to_string)
119        .collect()
120}
121
122fn is_absolute_path_token(word: &str) -> bool {
123    !word.is_empty() && Path::new(word).is_absolute()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::{sanitize_fs_component, sanitize_operator_message};
129
130    #[test]
131    fn filesystem_components_are_flat_and_safe() {
132        assert_eq!(
133            sanitize_fs_component("feature/new*wave"),
134            "feature-new-wave"
135        );
136        assert_eq!(sanitize_fs_component("../.."), "wave");
137    }
138
139    #[test]
140    fn sanitize_operator_message_redacts_paths_and_tokens() {
141        let raw = "failed at /tmp/worktree with Bearer abcdef0123456789abcdef0123456789";
142        let sanitized = sanitize_operator_message(raw);
143        assert!(!sanitized.contains("/tmp/worktree"));
144        assert!(!sanitized.contains("abcdef0123456789abcdef0123456789"));
145        assert!(sanitized.contains("[REDACTED_PATH]"));
146        assert!(sanitized.contains("[REDACTED_TOKEN]"));
147    }
148
149    #[test]
150    fn sanitize_operator_message_redacts_home_path() {
151        let Some(home) = dirs::home_dir() else {
152            return;
153        };
154        let raw = format!("cannot open {}", home.display());
155        let sanitized = sanitize_operator_message(&raw);
156        assert!(!sanitized.contains(&home.to_string_lossy().to_string()));
157        assert!(sanitized.contains("[REDACTED_PATH]"));
158    }
159}