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
26pub 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}