Skip to main content

sqz_engine/
confidence_router.rs

1/// Confidence-driven compression mode router.
2///
3/// Analyzes input content and selects the appropriate compression aggressiveness:
4/// - High-risk content (stack traces, configs, migrations) → safe preset
5/// - Low-entropy repetitive content → aggressive preset
6/// - Normal content → default preset
7///
8/// Based on entropy analysis and content pattern detection.
9
10use crate::entropy_analyzer::EntropyAnalyzer;
11
12/// The compression mode selected by the router.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CompressionMode {
15    /// Safe mode: minimal compression, preserve all structure.
16    /// Used for stack traces, migrations and credentials.
17    Safe,
18    /// Default mode: balanced compression.
19    Default,
20    /// Aggressive mode: maximum compression.
21    /// Used for repetitive/boilerplate content with low information density.
22    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
35/// Routes content to the appropriate compression mode.
36pub 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    /// Analyze content and return the recommended compression mode.
54    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        // Check for high-risk content patterns first (always → Safe)
61        if self.is_high_risk(content) {
62            return CompressionMode::Safe;
63        }
64
65        // Use entropy to distinguish repetitive from normal content
66        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        // Very low average entropy → mostly boilerplate → aggressive
74        if avg_entropy < 2.5 {
75            return CompressionMode::Aggressive;
76        }
77
78        // High entropy = information-dense content (code, git log, varied text).
79        // This is NOT the same as high-risk. Route to Default, not Safe.
80        // Safe mode is only for content that is_high_risk() explicitly identifies.
81        CompressionMode::Default
82    }
83
84    /// Whether content must stay out of the persistent cache.
85    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    /// Returns true if content contains high-risk patterns that should
91    /// never be aggressively compressed.
92    ///
93    /// These checks require structural context — not just keyword presence.
94    /// A commit message saying "fix: password reset" is NOT a secret.
95    /// A stack trace with its frames IS high-risk.
96    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        // Stack traces — require structural markers, not just keywords.
105        // "panicked at" alone in a commit message is not a stack trace.
106        // A real stack trace has "panicked at" PLUS frame numbers or "stack backtrace".
107        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        // Database migrations — require SQL keywords, not just the word "migration".
117        // "fix: migration path" in a commit message is NOT a database migration.
118        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        // Security/auth configs — require structural context.
126        // "fix: password reset" is not a secret. But "password: abc123" is.
127        if lower.contains("-----begin") {
128            // PEM headers are always high-risk
129            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        // "password" only high-risk when it looks like a config value, not a commit message
138        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        // Kubernetes/infrastructure configs with critical fields
146        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    /// Heuristic: does this content look like a git commit log?
156    /// Commit logs have lines starting with short hashes (7+ hex chars)
157    /// or conventional commit prefixes (feat:, fix:, chore:, etc.).
158    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            // Short hash prefix: "abc1234 feat: ...". Take chars (not a
164            // byte slice) — byte 7 can land inside a multi-byte character
165            // and panic (issue #34: Cyrillic output + a password marker).
166            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            // Conventional commit prefix
173            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// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[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    /// Regression for issue #34: routing multi-byte text through the
344    /// commit-log heuristic panicked on `trimmed[..7]` when byte 7 landed
345    /// inside a multi-byte character. The password marker is what pulls
346    /// the input into `looks_like_commit_log`; the Cyrillic line is what
347    /// used to blow up there.
348    #[test]
349    fn multibyte_input_with_password_marker_does_not_panic() {
350        let router = ConfidenceRouter::new();
351        // Must clear the router's 100-byte minimum or is_high_risk (and
352        // the vulnerable heuristic behind it) never runs.
353        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        // High-risk (password) content routes to Safe; the point of the
360        // test is that `route` returns at all instead of panicking.
361        assert_eq!(router.route(content), CompressionMode::Safe);
362        assert!(router.never_store(content));
363    }
364
365    /// Same class, CJK + emoji: no byte offset in these strings may be
366    /// assumed to be a char boundary.
367    #[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        // Very repetitive content → low entropy → aggressive
415        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        // Should be aggressive or default (low entropy)
418        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        // PEM key must be > 100 chars to trigger routing
440        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}