Skip to main content

rch_common/
util.rs

1//! Shared utilities for RCH.
2
3/// Truncate a string to at most `max_bytes` bytes, snapping back to the nearest
4/// UTF-8 character boundary.
5///
6/// `&s[..n]` panics if byte `n` falls mid-codepoint. Every display path that
7/// truncates user- or worker-supplied strings must snap to a char boundary
8/// first — otherwise a rogue command like `café build` at exactly the wrong
9/// length will crash the whole hook.
10pub fn truncate_at_char_boundary(value: &str, max_bytes: usize) -> &str {
11    if value.len() <= max_bytes {
12        return value;
13    }
14    let mut end = max_bytes;
15    while end > 0 && !value.is_char_boundary(end) {
16        end -= 1;
17    }
18    &value[..end]
19}
20
21fn find_value_end(s: &str) -> usize {
22    let chars = s.chars();
23    let mut end = 0;
24    let mut in_quote = None;
25    let mut escaped = false;
26
27    for c in chars {
28        let char_len = c.len_utf8();
29
30        if escaped {
31            escaped = false;
32            end += char_len;
33            continue;
34        }
35
36        if c == '\\' {
37            escaped = true;
38            end += char_len;
39            continue;
40        }
41
42        if let Some(q) = in_quote {
43            if c == q {
44                in_quote = None;
45            }
46            end += char_len;
47            continue;
48        }
49
50        if c == '"' || c == '\'' {
51            in_quote = Some(c);
52            end += char_len;
53            continue;
54        }
55
56        if c.is_whitespace() {
57            break;
58        }
59
60        end += char_len;
61    }
62    end
63}
64
65/// Mask sensitive patterns in a command string before logging.
66///
67/// This prevents accidental exposure of API keys, passwords, and tokens
68/// that may be present in environment variables or command arguments.
69pub fn mask_sensitive_command(cmd: &str) -> String {
70    // Patterns to mask (case-insensitive matching would be better, but this is simple)
71    // We replace the value part with "***" while keeping the key/flag.
72    let patterns = [
73        // Environment variable patterns
74        ("CARGO_REGISTRY_TOKEN=", "CARGO_REGISTRY_TOKEN=***"),
75        ("GITHUB_TOKEN=", "GITHUB_TOKEN=***"),
76        ("GH_TOKEN=", "GH_TOKEN=***"),
77        ("DATABASE_URL=", "DATABASE_URL=***"),
78        ("DB_PASSWORD=", "DB_PASSWORD=***"),
79        ("API_KEY=", "API_KEY=***"),
80        ("API_SECRET=", "API_SECRET=***"),
81        ("SECRET_KEY=", "SECRET_KEY=***"),
82        ("SECRET=", "SECRET=***"),
83        ("PASSWORD=", "PASSWORD=***"),
84        ("PASS=", "PASS=***"),
85        ("TOKEN=", "TOKEN=***"),
86        ("AUTH_TOKEN=", "AUTH_TOKEN=***"),
87        ("ACCESS_TOKEN=", "ACCESS_TOKEN=***"),
88        ("PRIVATE_KEY=", "PRIVATE_KEY=***"),
89        ("AWS_SECRET_ACCESS_KEY=", "AWS_SECRET_ACCESS_KEY=***"),
90        ("AWS_ACCESS_KEY_ID=", "AWS_ACCESS_KEY_ID=***"),
91        ("STRIPE_SECRET_KEY=", "STRIPE_SECRET_KEY=***"),
92        ("OPENAI_API_KEY=", "OPENAI_API_KEY=***"),
93        ("ANTHROPIC_API_KEY=", "ANTHROPIC_API_KEY=***"),
94        // Any other `*_KEY=` (GROQ_KEY, SIGNING_KEY, ...): a harmless key
95        // name masked costs a log detail; an unlisted secret one leaks.
96        ("_KEY=", "_KEY=***"),
97        // Command-line argument patterns (--token, --password, etc.)
98        ("--token ", "--token ***"),
99        ("--token=", "--token=***"),
100        ("--password ", "--password ***"),
101        ("--password=", "--password=***"),
102        ("--api-key ", "--api-key ***"),
103        ("--api-key=", "--api-key=***"),
104        ("--secret ", "--secret ***"),
105        ("--secret=", "--secret=***"),
106    ];
107
108    let mut result = cmd.to_string();
109    for (pattern, replacement) in patterns {
110        // Loop to handle multiple occurrences of the same pattern
111        // Track search position to avoid infinite loop (replacement contains pattern)
112        let mut search_start = 0;
113        while search_start < result.len() {
114            let Some(start) = result[search_start..].find(pattern) else {
115                break;
116            };
117            let abs_start = search_start + start;
118            let value_start = abs_start + pattern.len();
119
120            let rest = &result[value_start..];
121            let value_end = value_start + find_value_end(rest);
122
123            // Replace the value portion
124            let prefix = &result[..abs_start];
125            let suffix = &result[value_end..];
126            result = format!("{}{}{}", prefix, replacement, suffix);
127
128            // Move past the replacement to avoid re-matching
129            search_start = abs_start + replacement.len();
130        }
131    }
132
133    result
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_mask_sensitive_command() {
142        let cmd = "cargo run --release TOKEN=secret123 GITHUB_TOKEN=abcdef --token mytoken --password=hidden";
143        let masked = mask_sensitive_command(cmd);
144
145        assert!(masked.contains("TOKEN=***"));
146        assert!(!masked.contains("secret123"));
147
148        assert!(masked.contains("GITHUB_TOKEN=***"));
149        assert!(!masked.contains("abcdef"));
150
151        assert!(masked.contains("--token ***"));
152        assert!(!masked.contains("mytoken"));
153
154        assert!(masked.contains("--password=***"));
155        assert!(!masked.contains("hidden"));
156    }
157
158    #[test]
159    fn test_mask_sensitive_command_multiple() {
160        let cmd = "TOKEN=a TOKEN=b";
161        let masked = mask_sensitive_command(cmd);
162        assert_eq!(masked, "TOKEN=*** TOKEN=***");
163    }
164
165    #[test]
166    fn test_mask_sensitive_command_quoted() {
167        let cmd = "cargo run TOKEN=\"my super secret\" --other";
168        let masked = mask_sensitive_command(cmd);
169        assert_eq!(masked, "cargo run TOKEN=*** --other");
170        assert!(!masked.contains("super"));
171        assert!(!masked.contains("secret"));
172    }
173
174    #[test]
175    fn test_truncate_at_char_boundary_short_string_unchanged() {
176        assert_eq!(truncate_at_char_boundary("abc", 10), "abc");
177        assert_eq!(truncate_at_char_boundary("", 10), "");
178        assert_eq!(truncate_at_char_boundary("exactly10!", 10), "exactly10!");
179    }
180
181    #[test]
182    fn test_truncate_at_char_boundary_ascii() {
183        assert_eq!(truncate_at_char_boundary("hello world", 5), "hello");
184        assert_eq!(truncate_at_char_boundary("abcdef", 3), "abc");
185    }
186
187    #[test]
188    fn test_truncate_at_char_boundary_snaps_back_from_mid_codepoint() {
189        // "é" is 2 bytes in UTF-8 (0xC3 0xA9). Requesting len=1 would land
190        // mid-codepoint; the helper must snap back to 0 rather than panic.
191        assert_eq!(truncate_at_char_boundary("é", 1), "");
192        // Requesting a boundary past the last codepoint returns the string.
193        assert_eq!(truncate_at_char_boundary("é", 2), "é");
194        // Mixed ASCII/multi-byte: "café" is 5 bytes. At len=4 we're between
195        // "caf" and "é" — safe. At len=3 we're at "caf". At len=2 we're "ca".
196        assert_eq!(truncate_at_char_boundary("café", 4), "caf");
197        assert_eq!(truncate_at_char_boundary("café", 3), "caf");
198        assert_eq!(truncate_at_char_boundary("café", 5), "café");
199    }
200
201    #[test]
202    fn test_truncate_at_char_boundary_never_panics_on_len_zero() {
203        assert_eq!(truncate_at_char_boundary("hello", 0), "");
204        assert_eq!(truncate_at_char_boundary("é", 0), "");
205    }
206}