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, configs, migration files, legal text.
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        if content.len() < 100 {
56            return CompressionMode::Default;
57        }
58
59        // Check for high-risk content patterns first (always → Safe)
60        if self.is_high_risk(content) {
61            return CompressionMode::Safe;
62        }
63
64        // Use entropy to distinguish repetitive from normal content
65        let blocks = self.entropy_analyzer.analyze(content);
66        if blocks.is_empty() {
67            return CompressionMode::Default;
68        }
69
70        let avg_entropy: f64 = blocks.iter().map(|b| b.entropy).sum::<f64>() / blocks.len() as f64;
71
72        // Very low average entropy → mostly boilerplate → aggressive
73        if avg_entropy < 2.5 {
74            return CompressionMode::Aggressive;
75        }
76
77        // High entropy = information-dense content (code, git log, varied text).
78        // This is NOT the same as high-risk. Route to Default, not Safe.
79        // Safe mode is only for content that is_high_risk() explicitly identifies.
80        CompressionMode::Default
81    }
82
83    /// Returns true if content contains high-risk patterns that should
84    /// never be aggressively compressed.
85    ///
86    /// These checks require structural context — not just keyword presence.
87    /// A commit message saying "fix: password reset" is NOT a secret.
88    /// A stack trace saying "panicked at" IS high-risk.
89    fn is_high_risk(&self, content: &str) -> bool {
90        let lower = content.to_lowercase();
91
92        // Stack traces — require structural markers, not just keywords.
93        // "panicked at" alone in a commit message is not a stack trace.
94        // A real stack trace has "panicked at" PLUS frame numbers or "stack backtrace".
95        if (lower.contains("panicked at") || lower.contains("traceback"))
96            && (lower.contains("stack backtrace") || lower.contains("  0:") || lower.contains("  at "))
97        {
98            return true;
99        }
100        if lower.contains("stack trace") && lower.contains("  at ") {
101            return true;
102        }
103
104        // Database migrations — require SQL keywords, not just the word "migration".
105        // "fix: migration path" in a commit message is NOT a database migration.
106        if (lower.contains("alter table") || lower.contains("create table") || lower.contains("drop table"))
107            && (lower.contains("column") || lower.contains("index") || lower.contains("constraint")
108                || lower.contains("primary key") || lower.contains("references"))
109        {
110            return true;
111        }
112
113        // Security/auth configs — require structural context.
114        // "fix: password reset" is not a secret. But "password: abc123" is.
115        if lower.contains("-----begin") {
116            // PEM headers are always high-risk
117            return true;
118        }
119        if (lower.contains("private_key") || lower.contains("secret_key") || lower.contains("api_key"))
120            && (lower.contains('=') || lower.contains(':'))
121            && !self.looks_like_commit_log(&lower)
122        {
123            return true;
124        }
125        // "password" only high-risk when it looks like a config value, not a commit message
126        if lower.contains("password")
127            && (lower.contains("password:") || lower.contains("password=") || lower.contains("password \""))
128            && !self.looks_like_commit_log(&lower)
129        {
130            return true;
131        }
132
133        // Legal/compliance text
134        if lower.contains("terms of service") || lower.contains("privacy policy")
135            || lower.contains("license agreement") || lower.contains("gdpr")
136        {
137            return true;
138        }
139
140        // Kubernetes/infrastructure configs with critical fields
141        if lower.contains("apiversion:") && lower.contains("kind:")
142            && (lower.contains("secret") || lower.contains("configmap"))
143        {
144            return true;
145        }
146
147        false
148    }
149
150    /// Heuristic: does this content look like a git commit log?
151    /// Commit logs have lines starting with short hashes (7+ hex chars)
152    /// or conventional commit prefixes (feat:, fix:, chore:, etc.).
153    fn looks_like_commit_log(&self, lower: &str) -> bool {
154        let lines: Vec<&str> = lower.lines().take(10).collect();
155        let mut commit_lines = 0;
156        for line in &lines {
157            let trimmed = line.trim();
158            // Short hash prefix: "abc1234 feat: ...". Take chars (not a
159            // byte slice) — byte 7 can land inside a multi-byte character
160            // and panic (issue #34: Cyrillic output + a password marker).
161            if trimmed.len() > 8
162                && trimmed.chars().take(7).all(|c| c.is_ascii_hexdigit())
163                && trimmed.chars().count() > 7
164            {
165                commit_lines += 1;
166            }
167            // Conventional commit prefix
168            if trimmed.starts_with("feat:") || trimmed.starts_with("fix:")
169                || trimmed.starts_with("chore:") || trimmed.starts_with("docs:")
170                || trimmed.starts_with("refactor:") || trimmed.starts_with("test:")
171                || trimmed.starts_with("ci:") || trimmed.starts_with("style:")
172                || trimmed.starts_with("perf:")
173            {
174                commit_lines += 1;
175            }
176        }
177        commit_lines > 0 && commit_lines >= lines.len() / 3
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn routes_stack_trace_to_safe() {
191        let router = ConfidenceRouter::new();
192        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";
193        assert_eq!(router.route(trace), CompressionMode::Safe);
194    }
195
196    #[test]
197    fn routes_migration_to_safe() {
198        let router = ConfidenceRouter::new();
199        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));";
200        assert_eq!(router.route(migration), CompressionMode::Safe);
201    }
202
203    /// Regression for issue #34: routing multi-byte text through the
204    /// commit-log heuristic panicked on `trimmed[..7]` when byte 7 landed
205    /// inside a multi-byte character. The password marker is what pulls
206    /// the input into `looks_like_commit_log`; the Cyrillic line is what
207    /// used to blow up there.
208    #[test]
209    fn multibyte_input_with_password_marker_does_not_panic() {
210        let router = ConfidenceRouter::new();
211        // Must clear the router's 100-byte minimum or is_high_risk (and
212        // the vulnerable heuristic behind it) never runs.
213        let content = "Привет мир строка номер 0\n\
214                       Привет мир строка номер 1\n\
215                       Привет мир строка номер 2\n\
216                       пароль тут\n\
217                       password: тайна";
218        assert!(content.len() >= 100, "fixture must reach is_high_risk");
219        // High-risk (password) content routes to Safe; the point of the
220        // test is that `route` returns at all instead of panicking.
221        assert_eq!(router.route(content), CompressionMode::Safe);
222    }
223
224    /// Same class, CJK + emoji: no byte offset in these strings may be
225    /// assumed to be a char boundary.
226    #[test]
227    fn cjk_and_emoji_input_does_not_panic() {
228        let router = ConfidenceRouter::new();
229        let cjk = "私はガラスを食べられます。それは私を傷つけません。\n\
230                   api_key = 秘密🦀\n\
231                   日本語のテキストの行がもう一つあります";
232        assert!(cjk.len() >= 100);
233        let _ = router.route(cjk);
234    }
235
236    #[test]
237    fn git_log_with_fix_is_not_high_risk() {
238        let router = ConfidenceRouter::new();
239        let git_log = "abc1234 fix: password reset flow\ndef5678 feat: migrate user auth to OAuth\n\
240                       1234567 chore: update dependencies\n8901234 fix: migration path handling\n\
241                       abcdef0 refactor: clean up error handling\n1111111 docs: update README\n\
242                       2222222 fix: handle edge case in parser\n3333333 feat: add new API endpoint";
243        let mode = router.route(git_log);
244        assert_ne!(mode, CompressionMode::Safe,
245            "git log with 'fix:' and 'migration' should NOT trigger safe mode");
246    }
247
248    #[test]
249    fn commit_message_with_password_is_not_high_risk() {
250        let router = ConfidenceRouter::new();
251        let git_log = "abc1234 fix: password reset flow broken on mobile\n\
252                       def5678 feat: add password strength indicator\n\
253                       1234567 chore: update password hashing library\n\
254                       8901234 fix: password validation regex\n\
255                       abcdef0 test: add password reset integration tests";
256        let mode = router.route(git_log);
257        assert_ne!(mode, CompressionMode::Safe,
258            "git log mentioning 'password' should NOT trigger safe mode");
259    }
260
261    #[test]
262    fn actual_password_config_is_high_risk() {
263        let router = ConfidenceRouter::new();
264        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";
265        assert_eq!(router.route(config), CompressionMode::Safe,
266            "config file with password: value should trigger safe mode");
267    }
268
269    #[test]
270    fn routes_repetitive_logs_to_aggressive() {
271        let router = ConfidenceRouter::new();
272        // Very repetitive content → low entropy → aggressive
273        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";
274        let mode = router.route(logs);
275        // Should be aggressive or default (low entropy)
276        assert!(mode == CompressionMode::Aggressive || mode == CompressionMode::Default);
277    }
278
279    #[test]
280    fn routes_normal_code_to_default() {
281        let router = ConfidenceRouter::new();
282        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}";
283        let mode = router.route(code);
284        assert_eq!(mode, CompressionMode::Default,
285            "normal code should route to Default, not Safe");
286    }
287
288    #[test]
289    fn short_content_is_default() {
290        let router = ConfidenceRouter::new();
291        assert_eq!(router.route("hello"), CompressionMode::Default);
292    }
293
294    #[test]
295    fn pem_key_routes_to_safe() {
296        let router = ConfidenceRouter::new();
297        // PEM key must be > 100 chars to trigger routing
298        let key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\nQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz==\n-----END RSA PRIVATE KEY-----\n";
299        assert_eq!(router.route(key), CompressionMode::Safe);
300    }
301}