1use crate::entropy_analyzer::EntropyAnalyzer;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CompressionMode {
15 Safe,
18 Default,
20 Aggressive,
23}
24
25impl CompressionMode {
26 pub fn as_str(&self) -> &'static str {
27 match self {
28 Self::Safe => "safe",
29 Self::Default => "default",
30 Self::Aggressive => "aggressive",
31 }
32 }
33}
34
35pub struct ConfidenceRouter {
37 entropy_analyzer: EntropyAnalyzer,
38}
39
40impl Default for ConfidenceRouter {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl ConfidenceRouter {
47 pub fn new() -> Self {
48 Self {
49 entropy_analyzer: EntropyAnalyzer::new(),
50 }
51 }
52
53 pub fn route(&self, content: &str) -> CompressionMode {
55 let content = &*without_ansi(content);
56 if content.len() < 100 {
57 return CompressionMode::Default;
58 }
59
60 if self.is_high_risk(content) {
62 return CompressionMode::Safe;
63 }
64
65 let blocks = self.entropy_analyzer.analyze(content);
67 if blocks.is_empty() {
68 return CompressionMode::Default;
69 }
70
71 let avg_entropy: f64 = blocks.iter().map(|b| b.entropy).sum::<f64>() / blocks.len() as f64;
72
73 if avg_entropy < 2.5 {
75 return CompressionMode::Aggressive;
76 }
77
78 CompressionMode::Default
82 }
83
84 pub fn never_store(&self, content: &str) -> bool {
86 let content = &*without_ansi(content);
87 content.len() >= 100 && (self.mentions_high_risk(content) || self.is_high_risk(content))
88 }
89
90 fn is_high_risk(&self, content: &str) -> bool {
97 let lines: Vec<&str> = content.lines().map(str::trim).collect();
98 (0..lines.len()).any(|i| is_risk_line(&lines, i))
99 }
100
101 fn mentions_high_risk(&self, content: &str) -> bool {
102 let lower = content.to_lowercase();
103
104 if (lower.contains("panicked at") || lower.contains("traceback"))
108 && (lower.contains("stack backtrace") || lower.contains(" 0:") || lower.contains(" at "))
109 {
110 return true;
111 }
112 if lower.contains("stack trace") && lower.contains(" at ") {
113 return true;
114 }
115
116 if (lower.contains("alter table") || lower.contains("create table") || lower.contains("drop table"))
119 && (lower.contains("column") || lower.contains("index") || lower.contains("constraint")
120 || lower.contains("primary key") || lower.contains("references"))
121 {
122 return true;
123 }
124
125 if lower.contains("-----begin") {
128 return true;
130 }
131 if (lower.contains("private_key") || lower.contains("secret_key") || lower.contains("api_key"))
132 && (lower.contains('=') || lower.contains(':'))
133 && !self.looks_like_commit_log(&lower)
134 {
135 return true;
136 }
137 if lower.contains("password")
139 && (lower.contains("password:") || lower.contains("password=") || lower.contains("password \""))
140 && !self.looks_like_commit_log(&lower)
141 {
142 return true;
143 }
144
145 if lower.contains("apiversion:") && lower.contains("kind:")
147 && (lower.contains("secret") || lower.contains("configmap"))
148 {
149 return true;
150 }
151
152 false
153 }
154
155 fn looks_like_commit_log(&self, lower: &str) -> bool {
159 let lines: Vec<&str> = lower.lines().take(10).collect();
160 let mut commit_lines = 0;
161 for line in &lines {
162 let trimmed = line.trim();
163 if trimmed.len() > 8
167 && trimmed.chars().take(7).all(|c| c.is_ascii_hexdigit())
168 && trimmed.chars().count() > 7
169 {
170 commit_lines += 1;
171 }
172 if trimmed.starts_with("feat:") || trimmed.starts_with("fix:")
174 || trimmed.starts_with("chore:") || trimmed.starts_with("docs:")
175 || trimmed.starts_with("refactor:") || trimmed.starts_with("test:")
176 || trimmed.starts_with("ci:") || trimmed.starts_with("style:")
177 || trimmed.starts_with("perf:")
178 {
179 commit_lines += 1;
180 }
181 }
182 commit_lines > 0 && commit_lines >= lines.len() / 3
183 }
184}
185
186pub(crate) fn protected_lines(content: &str) -> Vec<&str> {
187 if without_ansi(content).len() < 100 {
188 return Vec::new();
189 }
190 let raw: Vec<&str> = content.lines().collect();
191 let plain: Vec<std::borrow::Cow<str>> = raw.iter().map(|l| without_ansi(l)).collect();
192 let lines: Vec<&str> = plain.iter().map(|l| l.trim()).collect();
193 let mut protected = Vec::new();
194 let mut start = 0;
195 for end in 0..=lines.len() {
196 if lines.get(end).is_some_and(|l| !l.is_empty()) {
197 continue;
198 }
199 if (start..end).any(|i| is_risk_line(&lines, i)) {
200 protected.extend_from_slice(&raw[start..end]);
201 }
202 start = end + 1;
203 }
204 protected
205}
206
207fn without_ansi(text: &str) -> std::borrow::Cow<'_, str> {
208 if text.contains('\x1b') {
209 crate::ansi_strip::strip_ansi(text).into()
210 } else {
211 text.into()
212 }
213}
214
215fn is_risk_line(lines: &[&str], i: usize) -> bool {
216 let line = lines[i];
217 let next = lines.get(i + 1).copied().unwrap_or("");
218 let after = lines.get(i + 2).copied().unwrap_or("");
219 starts_trace(line, next, after) || is_ddl(line) || is_pem(line) || is_secret_line(line)
220}
221
222fn starts_trace(line: &str, next: &str, after: &str) -> bool {
223 (line.eq_ignore_ascii_case("stack backtrace:") && next.starts_with("0: "))
224 || (line.starts_with("Traceback (most recent call last)") && next.starts_with("File \""))
225 || (is_frame(next) && ["Error", "Exception", "Throwable"].iter().any(|w| line.contains(w)))
226 || (is_goroutine_header(line) && next.ends_with(')') && after.contains(".go:"))
227}
228
229fn is_frame(line: &str) -> bool {
230 line.strip_prefix("at ").is_some_and(|call| {
231 (call.contains('(') && call.contains(')')) || ends_with_line_col(call)
232 })
233}
234
235fn ends_with_line_col(call: &str) -> bool {
236 let mut parts = call.rsplitn(3, ':');
237 let (Some(col), Some(row), Some(_)) = (parts.next(), parts.next(), parts.next()) else {
238 return false;
239 };
240 [col, row].iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
241}
242
243fn is_goroutine_header(line: &str) -> bool {
244 line.strip_prefix("goroutine ")
245 .and_then(|rest| rest.split_once(" ["))
246 .is_some_and(|(id, state)| {
247 !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) && state.ends_with("]:")
248 })
249}
250
251fn is_ddl(line: &str) -> bool {
252 const DDL: &[&str] = &[
253 "create table ", "alter table ", "drop table ", "create index ", "create unique index ",
254 "drop index ",
255 ];
256 DDL.iter()
257 .any(|k| line.get(..k.len()).is_some_and(|head| head.eq_ignore_ascii_case(k)))
258}
259
260fn is_pem(line: &str) -> bool {
261 line.starts_with("-----BEGIN ") && line.ends_with("-----")
262}
263
264fn is_secret_line(line: &str) -> bool {
265 const NAMES: &[&str] = &[
266 "password", "passwd", "passphrase", "secret", "apikey", "privatekey", "secretkey",
267 "accesskey", "token", "authorization",
268 ];
269 const NOT_SECRET: &[&str] = &["nexttoken", "pagetoken", "continuationtoken"];
270 const WORDS: &[&str] = &["null", "none", "nil", "true", "false", "read", "write", "str", "string"];
271 let norm = |s: &str| -> String {
272 s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
273 };
274 let line = line.strip_prefix("export ").unwrap_or(line);
275 let Some(at) = line.find(['=', ':']) else { return false };
276 let key = line[..at].trim_end().trim_matches(['"', '\'']);
277 if key.is_empty()
278 || key.len() > 64
279 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
280 {
281 return false;
282 }
283 let name = norm(key);
284 if !NAMES.iter().any(|n| name.ends_with(n)) || NOT_SECRET.iter().any(|n| name.ends_with(n)) {
285 return false;
286 }
287 let rest = line[at + 1..].trim();
288 let assigned = match rest.split_once(" = ") {
289 Some((_, v)) if !rest.starts_with(['"', '\'']) => v.trim(),
290 _ => rest,
291 };
292 let value = assigned.trim_end_matches(',').trim_matches(['"', '\'']);
293 let code = !rest.starts_with(['"', '\''])
294 && (rest.ends_with([',', ';']) || rest.contains(['(', ')', '[', ']', '{', '}', '<', '>', '&']));
295 let bare = value.trim_start_matches(['^', '~', '=', '>', '<']);
296 let bare = bare.strip_prefix('v').unwrap_or(bare);
297 let numbers = bare.split(['-', '+']).next().unwrap_or(bare);
298 let version = numbers.contains('.')
299 && numbers.split('.').all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
300 && bare.bytes().all(|b| b.is_ascii_alphanumeric() || b"-+.".contains(&b));
301 if rest.starts_with(':')
302 || code
303 || value.is_empty()
304 || WORDS.iter().any(|w| value.eq_ignore_ascii_case(w))
305 || version
306 {
307 return false;
308 }
309 let last = norm(value.rsplit('.').next().unwrap_or(value));
310 let reference = (line.as_bytes()[at] == b'=' || assigned.len() < rest.len())
311 && !assigned.starts_with(['"', '\''])
312 && value.split('.').all(|s| {
313 s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
314 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
315 })
316 && (last == norm(key.rsplit('.').next().unwrap_or(key))
317 || (value.contains('.') && NAMES.iter().any(|n| last.ends_with(n))));
318 !reference
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn routes_stack_trace_to_safe() {
331 let router = ConfidenceRouter::new();
332 let trace = "thread 'main' panicked at 'index out of bounds', src/main.rs:42\nstack backtrace:\n 0: std::panicking::begin_panic\n 1: myapp::process\n 2: main";
333 assert_eq!(router.route(trace), CompressionMode::Safe);
334 }
335
336 #[test]
337 fn routes_migration_to_safe() {
338 let router = ConfidenceRouter::new();
339 let migration = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL;\nCREATE TABLE sessions (id UUID PRIMARY KEY, user_id INT REFERENCES users(id));";
340 assert_eq!(router.route(migration), CompressionMode::Safe);
341 }
342
343 #[test]
349 fn multibyte_input_with_password_marker_does_not_panic() {
350 let router = ConfidenceRouter::new();
351 let content = "Привет мир строка номер 0\n\
354 Привет мир строка номер 1\n\
355 Привет мир строка номер 2\n\
356 пароль тут\n\
357 password: тайна";
358 assert!(content.len() >= 100, "fixture must reach is_high_risk");
359 assert_eq!(router.route(content), CompressionMode::Safe);
362 assert!(router.never_store(content));
363 }
364
365 #[test]
368 fn cjk_and_emoji_input_does_not_panic() {
369 let router = ConfidenceRouter::new();
370 let cjk = "私はガラスを食べられます。それは私を傷つけません。\n\
371 api_key = 秘密🦀\n\
372 日本語のテキストの行がもう一つあります";
373 assert!(cjk.len() >= 100);
374 let _ = router.route(cjk);
375 let _ = router.never_store(cjk);
376 }
377
378 #[test]
379 fn git_log_with_fix_is_not_high_risk() {
380 let router = ConfidenceRouter::new();
381 let git_log = "abc1234 fix: password reset flow\ndef5678 feat: migrate user auth to OAuth\n\
382 1234567 chore: update dependencies\n8901234 fix: migration path handling\n\
383 abcdef0 refactor: clean up error handling\n1111111 docs: update README\n\
384 2222222 fix: handle edge case in parser\n3333333 feat: add new API endpoint";
385 let mode = router.route(git_log);
386 assert_ne!(mode, CompressionMode::Safe,
387 "git log with 'fix:' and 'migration' should NOT trigger safe mode");
388 }
389
390 #[test]
391 fn commit_message_with_password_is_not_high_risk() {
392 let router = ConfidenceRouter::new();
393 let git_log = "abc1234 fix: password reset flow broken on mobile\n\
394 def5678 feat: add password strength indicator\n\
395 1234567 chore: update password hashing library\n\
396 8901234 fix: password validation regex\n\
397 abcdef0 test: add password reset integration tests";
398 let mode = router.route(git_log);
399 assert_ne!(mode, CompressionMode::Safe,
400 "git log mentioning 'password' should NOT trigger safe mode");
401 }
402
403 #[test]
404 fn actual_password_config_is_high_risk() {
405 let router = ConfidenceRouter::new();
406 let config = "database:\n host: localhost\n port: 5432\n username: admin\n password: super_secret_123\n database: myapp_prod\n ssl: true\n pool_size: 10";
407 assert_eq!(router.route(config), CompressionMode::Safe,
408 "config file with password: value should trigger safe mode");
409 }
410
411 #[test]
412 fn routes_repetitive_logs_to_aggressive() {
413 let router = ConfidenceRouter::new();
414 let logs = "// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n// comment\n";
416 let mode = router.route(logs);
417 assert!(mode == CompressionMode::Aggressive || mode == CompressionMode::Default);
419 }
420
421 #[test]
422 fn routes_normal_code_to_default() {
423 let router = ConfidenceRouter::new();
424 let code = "fn process(items: &[Item]) -> Result<Vec<Output>, Error> {\n let mut results = Vec::new();\n for item in items {\n let output = transform(item)?;\n results.push(output);\n }\n Ok(results)\n}";
425 let mode = router.route(code);
426 assert_eq!(mode, CompressionMode::Default,
427 "normal code should route to Default, not Safe");
428 }
429
430 #[test]
431 fn short_content_is_default() {
432 let router = ConfidenceRouter::new();
433 assert_eq!(router.route("hello"), CompressionMode::Default);
434 }
435
436 #[test]
437 fn pem_key_routes_to_safe() {
438 let router = ConfidenceRouter::new();
439 let key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\nQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz==\n-----END RSA PRIVATE KEY-----\n";
441 assert_eq!(router.route(key), CompressionMode::Safe);
442 }
443
444 #[test]
445 fn routes_every_trace_format_to_safe() {
446 let router = ConfidenceRouter::new();
447 let padding = "worker started, waiting for jobs on the default queue\n".repeat(2);
448 for trace in [
449 "Traceback (most recent call last):\n File \"/srv/app/main.py\", line 42, in <module>\n main()\nValueError: bad refund amount\n",
450 "Exception in thread \"main\" java.lang.IllegalStateException: queue closed\n\tat com.acme.Queue.pop(Queue.java:88)\n\tat com.acme.Main.main(Main.java:12)\n",
451 "TypeError: Cannot read properties of undefined (reading 'id')\n at refund (/srv/app/refund.js:14:22)\n at /srv/app/index.js:3:9\n",
452 "panic: runtime error: index out of range [5] with length 3\n\ngoroutine 1 [running]:\nmain.main()\n\t/srv/app/main.go:8 +0x1d\nexit status 2\n",
453 "Error: config missing\n\nStack backtrace:\n 0: anyhow::error::object_drop\n 1: app::main\n",
454 ] {
455 let content = format!("{padding}{trace}");
456 assert_eq!(router.route(&content), CompressionMode::Safe, "{trace}");
457 assert!(router.never_store(&content), "{trace}");
458 }
459 }
460
461 #[test]
462 fn routes_credential_lines_to_safe() {
463 let router = ConfidenceRouter::new();
464 let padding = "service: billing\nregion: us-east-1\nreplicas: 3\nlog_level: info\n";
465 for line in [
466 "export STRIPE_SECRET_KEY=sk_test_51Pq9AbCd",
467 " \"apiKey\": \"ak_live_0123456789\",",
468 "db_password = \"hunter2\"",
469 "GITHUB_TOKEN=ghp_0123456789abcdef",
470 "aws_secret_access_key: wJalrXUtnFEMI",
471 "POSTGRES_PASSWORD: password",
472 "password: password",
473 "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
474 "api_key: str = \"sk_live_0123456789\"",
475 "token: v4.public.eyJzdWIiOiIxMjM0In0",
476 "token: 07401b.f395accd246ae52d",
477 "api_key = \"sk-proj-0123456789abcdef\"",
478 ] {
479 let content = format!("{padding}{line}\n{padding}");
480 assert_eq!(router.route(&content), CompressionMode::Safe, "{line}");
481 }
482 for line in [
483 "password:",
484 "next_token: eyJvZmZzZXQiOjEwfQ",
485 "secretName: db-creds",
486 "\"token\": null,",
487 "self.token = token",
488 "self._api_key = api_key",
489 "token = None",
490 "password = password",
491 "secret = secret",
492 "api_key = settings.STRIPE_API_KEY",
493 "id-token: write",
494 "\"jsonwebtoken\": \"^9.0.0\",",
495 "\"jsonwebtoken\": \"~1.2\",",
496 "\"jsonwebtoken\": \">=2.0.1\",",
497 "\"jsonwebtoken\": \"v1.2.3-beta.1\",",
498 "\"jsonwebtoken\": \"1.2.3+build.5\",",
499 "token: str = \"\"",
500 "token: str = \"\", grant_json: str | None = None,",
501 "password: str",
502 ] {
503 let content = format!("{padding}{line}\n{padding}");
504 assert_ne!(router.route(&content), CompressionMode::Safe, "{line}");
505 }
506 }
507
508 #[test]
509 fn markers_outside_their_structure_are_not_routed_but_never_stored() {
510 let router = ConfidenceRouter::new();
511 let log: String = (0..20)
512 .map(|i| format!("2026-10-05T12:00:{i:02}Z INFO GET /api/orders/{i} 200 3ms\n"))
513 .collect();
514 for content in [
515 format!("{log}2026-10-05T12:30:00Z DEBUG db config host=db.internal user=app password=hunter2\n{log}"),
516 format!("commit 1a2b3c4\n\n fix(router): no panic on a marker that panicked at byte 7, which ran\n at a momentarily-invalid home path.\n{log}"),
517 format!("+ conn.execute(\"CREATE TABLE sessions (id TEXT PRIMARY KEY)\")\n{log}"),
518 format!("let api_key = env::var(\"API_KEY\")?;\n{log}"),
519 ] {
520 assert_ne!(router.route(&content), CompressionMode::Safe, "{content}");
521 assert!(router.never_store(&content), "{content}");
522 }
523 }
524
525 #[test]
526 fn protected_lines_are_the_paragraphs_that_route_safe() {
527 let router = ConfidenceRouter::new();
528 let trace = "---- tests::refund stdout ----\nthread 'tests::refund' panicked at src/lib.rs:9:5:\nstack backtrace:\n 0: rust_begin_unwind\n 1: app::tests::refund\n at ./src/lib.rs:9:5";
529 let output = format!("running 3 tests\ntest tests::a ... ok\ntest tests::b ... ok\n\nfailures:\n\n{trace}\n\n\ntest result: FAILED. 2 passed; 1 failed\n");
530 assert_eq!(protected_lines(&output), trace.lines().collect::<Vec<_>>());
531 assert_eq!(router.route(&output), CompressionMode::Safe);
532 let clean = output.replace("stack backtrace:", "note: run with `RUST_BACKTRACE=1`");
533 assert!(protected_lines(&clean).is_empty());
534 assert_ne!(router.route(&clean), CompressionMode::Safe);
535 assert!(protected_lines("password: hunter2\n").is_empty());
536 }
537
538 #[test]
539 fn colored_content_is_routed_like_plain_content() {
540 let router = ConfidenceRouter::new();
541 let color = |s: &str| s.lines().map(|l| format!("\x1b[31m{l}\x1b[0m\n")).collect::<String>();
542 for plain in [
543 "thread 'main' panicked at src/q.rs:217:31:\ncalled `Option::unwrap()` on a `None` value\nstack backtrace:\n 0: rust_begin_unwind\n 1: app::main\n at ./src/main.rs:3:5\n",
544 "CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id INT);\nALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL;\n",
545 "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0123456789abcdefghijklmnopqrstuvwxyz\n-----END RSA PRIVATE KEY-----\n",
546 "service: billing\nregion: us-east-1\nGITHUB_TOKEN=ghp_0123456789abcdef\nreplicas: 3\nlog_level: info\npool_size: 10\n",
547 ] {
548 let colored = color(plain);
549 assert_eq!(router.route(plain), CompressionMode::Safe, "{plain}");
550 assert_eq!(router.route(&colored), CompressionMode::Safe, "{plain}");
551 assert!(router.never_store(&colored), "{plain}");
552 assert_eq!(protected_lines(&colored), colored.lines().collect::<Vec<_>>(), "{plain}");
553 }
554 }
555
556 #[test]
557 fn legal_text_is_routed_and_stored_like_any_page() {
558 let router = ConfidenceRouter::new();
559 let page = "<div class=\"row\"><a href=\"/item/1\">Item 1</a></div>\n".repeat(8)
560 + "<footer><a href=\"/privacy\">Privacy Policy</a> <a href=\"/terms\">Terms of Service</a></footer>\n";
561 assert_ne!(router.route(&page), CompressionMode::Safe);
562 assert!(!router.never_store(&page));
563 assert!(!router.never_store("password=hunter2"));
564 }
565}