ralph_workflow/common/
utils.rs1mod io;
9
10pub fn split_command(cmd: &str) -> std::io::Result<Vec<String>> {
25 let cmd = cmd.trim();
26 if cmd.is_empty() {
27 return Ok(vec![]);
28 }
29
30 shell_words::split(cmd).map_err(|err| {
31 std::io::Error::new(
32 std::io::ErrorKind::InvalidInput,
33 format!("Failed to parse command string: {err}"),
34 )
35 })
36}
37
38pub(crate) fn is_sensitive_key(key: &str) -> bool {
39 let key = key.trim().trim_start_matches('-').trim_start_matches('-');
40 let key = key
41 .split_once('=')
42 .or_else(|| key.split_once(':'))
43 .map_or(key, |(k, _)| k)
44 .trim()
45 .to_ascii_lowercase()
46 .replace('_', "-");
47
48 matches!(
49 key.as_str(),
50 "token"
51 | "access-token"
52 | "api-key"
53 | "apikey"
54 | "auth"
55 | "authorization"
56 | "bearer"
57 | "client-secret"
58 | "password"
59 | "pass"
60 | "passwd"
61 | "private-key"
62 | "secret"
63 )
64}
65
66fn redact_arg_value(key: &str, value: &str) -> String {
67 if is_sensitive_key(key) {
68 return "<redacted>".to_string();
69 }
70 io::secret_like_regex().map_or_else(
71 || value.to_string(),
72 |re| re.replace_all(value, "<redacted>").to_string(),
73 )
74}
75
76fn shell_quote_for_log(arg: &str) -> String {
77 if arg.is_empty() {
78 return "''".to_string();
79 }
80 if !arg
81 .chars()
82 .any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\'))
83 {
84 return arg.to_string();
85 }
86 let escaped = arg.replace('\'', r#"'\"'\"'"#);
87 format!("'{escaped}'")
88}
89
90pub fn format_argv_for_log(argv: &[String]) -> String {
92 let indices = 0..argv.len();
94 let out: Vec<String> = indices
95 .map(|i| {
96 let arg = &argv[i];
97 let prev_was_sensitive = i > 0 && is_sensitive_key(&argv[i - 1]);
99
100 if prev_was_sensitive {
101 return "<redacted>".to_string();
102 }
103
104 if let Some((k, v)) = arg.split_once('=') {
105 let env_key = k.to_ascii_uppercase();
107 let looks_like_secret_env = env_key.contains("TOKEN")
108 || env_key.contains("SECRET")
109 || env_key.contains("PASSWORD")
110 || env_key.contains("PASS")
111 || env_key.contains("KEY");
112 if is_sensitive_key(k) || looks_like_secret_env {
113 return format!("{}=<redacted>", shell_quote_for_log(k));
114 }
115 let redacted = redact_arg_value(k, v);
116 return shell_quote_for_log(&format!("{k}={redacted}"));
117 }
118
119 if is_sensitive_key(arg) {
120 return arg.to_string();
122 }
123
124 let redacted = io::secret_like_regex().map_or_else(
125 || arg.clone(),
126 |re| re.replace_all(arg, "<redacted>").to_string(),
127 );
128 shell_quote_for_log(&redacted)
129 })
130 .collect();
131
132 out.join(" ")
133}
134
135#[must_use]
147pub fn truncate_text(text: &str, limit: usize) -> String {
148 if limit <= 3 {
150 return text.chars().take(limit).collect();
151 }
152
153 let char_count = text.chars().count();
154 if char_count <= limit {
155 text.to_string()
156 } else {
157 let truncate_at = limit.saturating_sub(3);
159 let truncated: String = text.chars().take(truncate_at).collect();
160 format!("{truncated}...")
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_split_command_simple() {
170 let result = split_command("echo hello").unwrap();
171 assert_eq!(result, vec!["echo", "hello"]);
172 }
173
174 #[test]
175 fn test_split_command_with_quotes() {
176 let result = split_command("echo 'hello world'").unwrap();
177 assert_eq!(result, vec!["echo", "hello world"]);
178 }
179
180 #[test]
181 fn test_split_command_empty() {
182 let result = split_command("").unwrap();
183 assert!(result.is_empty());
184 }
185
186 #[test]
187 fn test_split_command_whitespace() {
188 let result = split_command(" ").unwrap();
189 assert!(result.is_empty());
190 }
191
192 #[test]
193 fn test_truncate_text_no_truncation() {
194 assert_eq!(truncate_text("hello", 10), "hello");
195 assert_eq!(truncate_text("hello", 5), "hello");
196 }
197
198 #[test]
199 fn test_truncate_text_with_ellipsis() {
200 assert_eq!(truncate_text("hello world", 8), "hello...");
202 }
203
204 #[test]
205 fn test_truncate_text_unicode() {
206 let text = "日本語テスト"; assert_eq!(truncate_text(text, 10), "日本語テスト");
209 assert_eq!(truncate_text(text, 6), "日本語テスト");
210 assert_eq!(truncate_text(text, 5), "日本...");
211 }
212
213 #[test]
214 fn test_truncate_text_emoji() {
215 let text = "Hello 👋 World";
217 assert_eq!(truncate_text(text, 20), "Hello 👋 World");
218 assert_eq!(truncate_text(text, 10), "Hello 👋...");
219 }
220
221 #[test]
222 fn test_truncate_text_edge_cases() {
223 assert_eq!(truncate_text("abc", 3), "abc");
224 assert_eq!(truncate_text("abcd", 3), "abc"); assert_eq!(truncate_text("ab", 1), "a");
226 assert_eq!(truncate_text("", 5), "");
227 }
228
229 #[test]
230 fn test_truncate_text_cjk_characters() {
231 let text = "日本語テスト"; assert_eq!(truncate_text(text, 4), "日...");
236 assert_eq!(truncate_text(text, 6), "日本語テスト");
238 }
239
240 #[test]
241 fn test_truncate_text_mixed_multibyte() {
242 let text = "Hello 世界 test"; assert_eq!(truncate_text(text, 20), "Hello 世界 test");
245 assert_eq!(truncate_text(text, 10), "Hello 世...");
247 }
248
249 #[test]
250 fn test_truncate_text_exact_boundary() {
251 let text = "ab日cd"; assert_eq!(truncate_text(text, 5), "ab日cd");
255 assert_eq!(truncate_text(text, 4), "a...");
257 }
258
259 #[test]
260 fn test_truncate_text_error_message_style() {
261 let text = "Error: ".to_string() + &"日".repeat(200);
263 let result = truncate_text(&text, 50);
264 assert!(result.ends_with("..."), "Result should end with '...'");
265 assert!(
267 result.chars().count() <= 50,
268 "Result char count {} exceeds limit 50",
269 result.chars().count()
270 );
271 }
272
273 #[test]
274 fn test_truncate_text_4byte_emoji() {
275 let text = "🎉🎊🎈"; assert_eq!(truncate_text(text, 3), "🎉🎊🎈"); assert_eq!(truncate_text(text, 4), "🎉🎊🎈"); assert_eq!(truncate_text(text, 5), "🎉🎊🎈");
282 assert_eq!(truncate_text(text, 2), "🎉🎊");
285 }
286
287 #[test]
288 fn test_truncate_text_combining_characters() {
289 let text = "cafe\u{0301}"; let result = truncate_text(text, 10);
293 assert_eq!(result, "cafe\u{0301}"); }
295}