Skip to main content

aicx_parser/
sanitize.rs

1//! Path and input sanitization for ai-contexters.
2//!
3//! Follows the established pattern:
4//! traversal check → canonicalize → allowlist validation.
5//!
6//! Prevents path traversal and command injection from user-supplied inputs
7//! (CLI arguments, project names, agent names).
8//!
9//! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders
10
11use anyhow::{Result, anyhow};
12use std::borrow::Cow;
13use std::fmt;
14use std::io::{self, BufRead, Read};
15use std::path::{Component, Path, PathBuf};
16
17/// Known safe extractor agent names.
18pub const ALLOWED_AGENTS: &[&str] = &[
19    "claude",
20    "codex",
21    "gemini",
22    "junie",
23    "codescribe",
24    "operator-md",
25];
26
27const AICX_ALLOW_TMP_ENV: &str = "AICX_ALLOW_TMP";
28
29pub const MAX_VALIDATED_BYTES: usize = 8 * 1024 * 1024;
30
31/// Separate cap for long-lived JSON state files (e.g. `state.json`). The
32/// generic 8 MiB validated-read cap is tuned for corpus / sidecar inputs,
33/// but a real AICX install accumulates many projects' `seen_hashes` and
34/// run history over months. Applying the generic cap would hard-fail on
35/// startup for legitimate large states. 128 MiB is well above any
36/// realistic state size yet still catches runaway growth or a corrupted
37/// file masquerading as state.
38pub const MAX_STATE_JSON_BYTES: usize = 128 * 1024 * 1024;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SanitizeError {
42    FileTooLarge {
43        path: PathBuf,
44        max_bytes: usize,
45        actual_bytes: u64,
46    },
47    StateFileTooLarge {
48        path: PathBuf,
49        max_bytes: usize,
50        actual_bytes: u64,
51    },
52}
53
54impl fmt::Display for SanitizeError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            SanitizeError::FileTooLarge {
58                path,
59                max_bytes,
60                actual_bytes,
61            } => write!(
62                f,
63                "File '{}' exceeds validated read cap: {} bytes > {} bytes",
64                path.display(),
65                actual_bytes,
66                max_bytes
67            ),
68            SanitizeError::StateFileTooLarge {
69                path,
70                max_bytes,
71                actual_bytes,
72            } => write!(
73                f,
74                "State file '{}' is too large: {} bytes > {} bytes. \
75                 This is not generic JSON corruption — the file exceeds the dedicated state cap. \
76                 Investigate runaway `seen_hashes` growth or restore from `.bak` before retrying.",
77                path.display(),
78                actual_bytes,
79                max_bytes
80            ),
81        }
82    }
83}
84
85impl std::error::Error for SanitizeError {}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum ContentSanitizationWarning {
89    NullByteStripped(usize),
90    BidiOverride(char, usize),
91    ZeroWidth(char, usize),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SanitizedContent<'a> {
96    pub text: Cow<'a, str>,
97    pub warnings: Vec<ContentSanitizationWarning>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct CappedLine {
102    pub line: String,
103    pub exceeded: bool,
104}
105
106// ============================================================================
107// Core helpers (mirroring rmcp-memex pattern)
108// ============================================================================
109
110/// Check if a path string contains traversal sequences.
111///
112/// Genuine path traversal is `..` as its own path component (e.g. `../`,
113/// `foo/../bar`). Substring matching against `..` falsely flags innocent
114/// directory names like `...`, `foo..bar`, or `a..b/c`, which broke
115/// real corpus iteration when ingest stored a literal three-dot folder.
116/// We split the path into components and only flag the canonical
117/// `Component::ParentDir`, plus the usual control characters.
118fn contains_traversal(path: &str) -> bool {
119    if path.contains('\0') || path.contains('\n') || path.contains('\r') {
120        return true;
121    }
122    Path::new(path)
123        .components()
124        .any(|c| matches!(c, Component::ParentDir))
125}
126
127fn current_user_allowed_bases() -> Result<Vec<PathBuf>> {
128    let mut bases = Vec::new();
129    for base in [dirs::home_dir(), dirs::cache_dir(), dirs::data_dir()]
130        .into_iter()
131        .flatten()
132    {
133        if !bases.iter().any(|existing| existing == &base) {
134            bases.push(base);
135        }
136    }
137
138    if bases.is_empty() {
139        return Err(anyhow!(
140            "Cannot determine current user allowed base directories"
141        ));
142    }
143
144    Ok(bases)
145}
146
147/// Canonicalize a path, returning error if it doesn't exist.
148fn canonicalize_existing(path: &Path) -> Result<PathBuf> {
149    path.canonicalize()
150        .map_err(|e| anyhow!("Cannot canonicalize path '{}': {}", path.display(), e))
151}
152
153/// Cargo test builds allow tempfile-backed `/tmp` paths, while normal debug and
154/// release builds require `AICX_ALLOW_TMP=1` so local dev runs follow the same
155/// explicit opt-in contract as production.
156fn temp_allowlist_enabled() -> bool {
157    temp_allowlist_enabled_for_runtime(cfg!(test), running_under_cargo_test_harness())
158}
159
160fn temp_allowlist_enabled_for_runtime(is_test_build: bool, is_cargo_test_harness: bool) -> bool {
161    is_test_build
162        || is_cargo_test_harness
163        || std::env::var(AICX_ALLOW_TMP_ENV).is_ok_and(|value| value == "1")
164}
165
166fn running_under_cargo_test_harness() -> bool {
167    std::env::current_exe()
168        .ok()
169        .is_some_and(|exe| is_cargo_test_exe_path(&exe))
170}
171
172fn is_cargo_test_exe_path(path: &Path) -> bool {
173    let has_deps_component = path.components().any(|component| {
174        matches!(
175            component,
176            Component::Normal(text) if text == std::ffi::OsStr::new("deps")
177        )
178    });
179    if !has_deps_component {
180        return false;
181    }
182
183    path.file_stem()
184        .and_then(|stem| stem.to_str())
185        .and_then(|stem| stem.rsplit_once('-'))
186        .is_some_and(|(_, suffix)| {
187            suffix.len() >= 8 && suffix.chars().all(|ch| ch.is_ascii_hexdigit())
188        })
189}
190
191fn is_temp_allowlist_path(path: &Path) -> bool {
192    path.starts_with("/tmp")
193        || path.starts_with("/var/folders")
194        || path.starts_with("/private/tmp")
195        || path.starts_with("/private/var/folders")
196}
197
198/// Validate that a path is under an allowed base directory.
199fn is_under_allowed_base(path: &Path) -> Result<bool> {
200    for base in current_user_allowed_bases()? {
201        if path.starts_with(base) {
202            return Ok(true);
203        }
204    }
205
206    if is_temp_allowlist_path(path) {
207        return Ok(temp_allowlist_enabled());
208    }
209
210    Ok(false)
211}
212
213// ============================================================================
214// Public API: path validation
215// ============================================================================
216
217/// Sanitize and validate a path that must exist (for reading).
218///
219/// Traversal check → canonicalize → allowlist.
220pub fn validate_read_path(path: &Path) -> Result<PathBuf> {
221    let path_str = path.to_string_lossy();
222    if contains_traversal(&path_str) {
223        return Err(anyhow!(
224            "Path contains invalid traversal sequence: {}",
225            path_str
226        ));
227    }
228
229    if !path.exists() {
230        return Err(anyhow!("Path does not exist: {}", path.display()));
231    }
232
233    let canonical = canonicalize_existing(path)?;
234
235    if !is_under_allowed_base(&canonical)? {
236        return Err(anyhow!(
237            "Cannot read from path outside allowed directories: {}",
238            canonical.display()
239        ));
240    }
241
242    Ok(canonical)
243}
244
245/// Sanitize and validate a path for writing (may not exist yet).
246///
247/// Traversal check → validate parent → allowlist.
248pub fn validate_write_path(path: &Path) -> Result<PathBuf> {
249    let path_str = path.to_string_lossy();
250    if contains_traversal(&path_str) {
251        return Err(anyhow!(
252            "Path contains invalid traversal sequence: {}",
253            path_str
254        ));
255    }
256
257    if path.exists() {
258        let canonical = canonicalize_existing(path)?;
259        if !is_under_allowed_base(&canonical)? {
260            return Err(anyhow!(
261                "Cannot write to path outside allowed directories: {}",
262                canonical.display()
263            ));
264        }
265        return Ok(canonical);
266    }
267
268    // New path — walk ancestors until we find an existing base directory and validate it.
269    let candidate = if path.is_absolute() {
270        path.to_path_buf()
271    } else {
272        std::env::current_dir()
273            .map_err(|e| anyhow!("Cannot determine current directory: {}", e))?
274            .join(path)
275    };
276
277    let mut ancestor = Some(candidate.as_path());
278    let mut existing_ancestor = None;
279    while let Some(current) = ancestor {
280        if current.exists() {
281            existing_ancestor = Some(canonicalize_existing(current)?);
282            break;
283        }
284        ancestor = current.parent();
285    }
286
287    let canonical_base = existing_ancestor.ok_or_else(|| {
288        anyhow!(
289            "Cannot validate write path '{}': no existing ancestor found",
290            path.display()
291        )
292    })?;
293
294    if !is_under_allowed_base(&canonical_base)? {
295        return Err(anyhow!(
296            "Path '{}' would be created outside allowed directories",
297            path.display()
298        ));
299    }
300
301    Ok(path.to_path_buf())
302}
303
304/// Sanitize a directory path used for reading (e.g., chunks_dir, contexts_dir).
305///
306/// Traversal check → canonicalize → allowlist. Must be a directory.
307pub fn validate_dir_path(path: &Path) -> Result<PathBuf> {
308    let validated = validate_read_path(path)?;
309    if !validated.is_dir() {
310        return Err(anyhow!("Path is not a directory: {}", validated.display()));
311    }
312    Ok(validated)
313}
314
315/// Open a file for reading only after validating the path.
316pub fn open_file_validated(path: &Path) -> Result<std::fs::File> {
317    let validated = validate_read_path(path)?;
318    std::fs::OpenOptions::new()
319        .read(true)
320        .open(&validated)
321        .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))
322}
323
324/// Create or truncate a file only after validating the write path.
325pub fn create_file_validated(path: &Path) -> Result<std::fs::File> {
326    let validated = validate_write_path(path)?;
327    std::fs::OpenOptions::new()
328        .create(true)
329        .truncate(true)
330        .write(true)
331        .open(&validated)
332        .map_err(|e| anyhow!("Failed to create '{}': {}", validated.display(), e))
333}
334
335/// Read a UTF-8 text file only after validating the path.
336pub fn read_to_string_validated(path: &Path) -> Result<String> {
337    read_to_string_with_cap(path, MAX_VALIDATED_BYTES, false)
338}
339
340/// Read AICX `state.json` (or its `.bak`) under the dedicated state cap.
341///
342/// Long-lived installs legitimately grow `seen_hashes`, run history, and
343/// per-project state into the tens of MiB. The generic 8 MiB cap is
344/// tuned for corpus inputs and would hard-fail those installs at
345/// startup, ahead of backup recovery. This entry point uses
346/// [`MAX_STATE_JSON_BYTES`] and surfaces a state-specific error so the
347/// failure mode is unmistakable (and obviously distinct from generic
348/// JSON corruption).
349pub fn read_state_json_validated(path: &Path) -> Result<String> {
350    read_to_string_with_cap(path, MAX_STATE_JSON_BYTES, true)
351}
352
353fn read_to_string_with_cap(path: &Path, max_bytes: usize, is_state: bool) -> Result<String> {
354    let validated = validate_read_path(path)?;
355    let file = std::fs::OpenOptions::new()
356        .read(true)
357        .open(&validated)
358        .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))?;
359    let metadata = file
360        .metadata()
361        .map_err(|e| anyhow!("Failed to stat '{}': {}", validated.display(), e))?;
362    if metadata.len() > max_bytes as u64 {
363        return Err(too_large_error(validated, max_bytes, metadata.len(), is_state).into());
364    }
365
366    let mut reader = file.take(max_bytes.saturating_add(1) as u64);
367    let mut bytes = Vec::new();
368    reader
369        .read_to_end(&mut bytes)
370        .map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))?;
371    if bytes.len() > max_bytes {
372        return Err(too_large_error(validated, max_bytes, bytes.len() as u64, is_state).into());
373    }
374    String::from_utf8(bytes).map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))
375}
376
377fn too_large_error(
378    path: PathBuf,
379    max_bytes: usize,
380    actual_bytes: u64,
381    is_state: bool,
382) -> SanitizeError {
383    if is_state {
384        SanitizeError::StateFileTooLarge {
385            path,
386            max_bytes,
387            actual_bytes,
388        }
389    } else {
390        SanitizeError::FileTooLarge {
391            path,
392            max_bytes,
393            actual_bytes,
394        }
395    }
396}
397
398/// Read a directory only after validating it as an allowed directory path.
399pub fn read_dir_validated(path: &Path) -> Result<std::fs::ReadDir> {
400    let validated = validate_dir_path(path)?;
401    // FP: `pub fn validate_dir_path(path: &Path) -> Result<PathBuf>`
402    // (line 302) delegates to `validate_read_path(path: &Path)` (line 215),
403    // which rejects traversal, canonicalizes the existing dir, and enforces
404    // the allowed-base policy before this directory iterator is created.
405    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- FP: validate_dir_path(Path) at line 302 -> validate_read_path(Path) at line 215 rejects traversal, canonicalizes, and enforces allowed-base policy.
406    std::fs::read_dir(&validated)
407        .map_err(|e| anyhow!("Failed to read dir '{}': {}", validated.display(), e))
408}
409
410pub fn read_line_capped<R: BufRead>(
411    reader: &mut R,
412    max_bytes: usize,
413) -> io::Result<Option<CappedLine>> {
414    let mut buf = Vec::new();
415    let read = {
416        let mut limited = reader.take(max_bytes.saturating_add(1) as u64);
417        limited.read_until(b'\n', &mut buf)?
418    };
419    if read == 0 {
420        return Ok(None);
421    }
422
423    let exceeded = buf.len() > max_bytes;
424    if exceeded {
425        let ended_at_newline = buf.last().copied() == Some(b'\n');
426        buf.truncate(max_bytes);
427        // Walk back past UTF-8 continuation bytes (0b10xxxxxx) so we don't
428        // chop a multi-byte sequence mid-codepoint; otherwise a valid input
429        // line would surface as InvalidData purely because of the cap.
430        while let Some(&last) = buf.last() {
431            if (last & 0xC0) == 0x80 {
432                buf.pop();
433            } else if last >= 0xC0 {
434                // Lead byte of a multi-byte sequence whose continuation bytes
435                // were just stripped — drop the lead too.
436                buf.pop();
437                break;
438            } else {
439                break;
440            }
441        }
442        if !ended_at_newline {
443            drain_until_newline(reader)?;
444        }
445    }
446
447    let line =
448        String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
449    Ok(Some(CappedLine { line, exceeded }))
450}
451
452fn drain_until_newline<R: BufRead>(reader: &mut R) -> io::Result<()> {
453    loop {
454        let available = reader.fill_buf()?;
455        if available.is_empty() {
456            return Ok(());
457        }
458        let consume = available
459            .iter()
460            .position(|byte| *byte == b'\n')
461            .map_or(available.len(), |idx| idx + 1);
462        let ended_at_newline = available.get(consume.saturating_sub(1)) == Some(&b'\n');
463        reader.consume(consume);
464        if ended_at_newline {
465            return Ok(());
466        }
467    }
468}
469
470// ============================================================================
471// Public API: input validation
472// ============================================================================
473
474/// Validate an agent name against the allowlist.
475///
476/// Prevents command injection by ensuring only known agent binaries
477/// are passed to `Command::new()`.
478pub fn safe_agent_name(name: &str) -> Result<&str> {
479    if ALLOWED_AGENTS.contains(&name) {
480        Ok(name)
481    } else {
482        Err(anyhow!(
483            "Unknown agent: {:?}. Allowed: {}",
484            name,
485            ALLOWED_AGENTS.join(", ")
486        ))
487    }
488}
489
490/// Sanitize a project name used in filesystem paths.
491///
492/// Rejects names containing path separators, traversal sequences,
493/// or control characters.
494pub fn safe_project_name(name: &str) -> Result<&str> {
495    if name.is_empty() {
496        return Err(anyhow!("Project name cannot be empty"));
497    }
498    if contains_traversal(name) || name.contains('/') || name.contains('\\') {
499        return Err(anyhow!("Invalid project name: {:?}", name));
500    }
501    Ok(name)
502}
503
504// ============================================================================
505// Public API: message content sanitization
506// ============================================================================
507
508pub fn sanitize_chunk_content(text: &str) -> SanitizedContent<'_> {
509    let (text, warnings) = sanitize_message_content(text);
510    SanitizedContent { text, warnings }
511}
512
513fn sanitize_message_content(input: &str) -> (Cow<'_, str>, Vec<ContentSanitizationWarning>) {
514    let mut output: Option<String> = None;
515    let mut warnings = Vec::new();
516    let mut chars = input.char_indices().peekable();
517
518    while let Some((offset, ch)) = chars.next() {
519        match ch {
520            '\0' => {
521                warnings.push(ContentSanitizationWarning::NullByteStripped(offset));
522                ensure_output(&mut output, input, offset);
523            }
524            '\r' => {
525                ensure_output(&mut output, input, offset);
526                output.as_mut().expect("output initialized").push('\n');
527                if chars.peek().is_some_and(|(_, next)| *next == '\n') {
528                    chars.next();
529                }
530            }
531            _ => {
532                if is_bidi_override(ch) {
533                    warnings.push(ContentSanitizationWarning::BidiOverride(ch, offset));
534                } else if is_zero_width(ch) {
535                    warnings.push(ContentSanitizationWarning::ZeroWidth(ch, offset));
536                }
537
538                if let Some(out) = output.as_mut() {
539                    out.push(ch);
540                }
541            }
542        }
543    }
544
545    match output {
546        Some(output) => (Cow::Owned(output), warnings),
547        None => (Cow::Borrowed(input), warnings),
548    }
549}
550
551fn ensure_output(output: &mut Option<String>, input: &str, offset: usize) {
552    if output.is_none() {
553        let mut owned = String::with_capacity(input.len());
554        owned.push_str(&input[..offset]);
555        *output = Some(owned);
556    }
557}
558
559fn is_bidi_override(ch: char) -> bool {
560    matches!(
561        ch,
562        '\u{202A}'
563            | '\u{202B}'
564            | '\u{202C}'
565            | '\u{202D}'
566            | '\u{202E}'
567            | '\u{2066}'
568            | '\u{2067}'
569            | '\u{2068}'
570            | '\u{2069}'
571    )
572}
573
574fn is_zero_width(ch: char) -> bool {
575    matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}')
576}
577
578// ============================================================================
579// Tests
580// ============================================================================
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::fs;
586    use std::sync::{Mutex, MutexGuard};
587
588    static ENV_MUTEX: Mutex<()> = Mutex::new(());
589
590    struct EnvVarGuard {
591        key: &'static str,
592        previous: Option<std::ffi::OsString>,
593        _guard: MutexGuard<'static, ()>,
594    }
595
596    impl EnvVarGuard {
597        fn set(key: &'static str, value: Option<&str>) -> Self {
598            let guard = ENV_MUTEX.lock().unwrap();
599            let previous = std::env::var_os(key);
600            // SAFETY: these tests serialize all mutations of this process env
601            // var and restore the previous value while holding the same mutex.
602            unsafe {
603                match value {
604                    Some(value) => std::env::set_var(key, value),
605                    None => std::env::remove_var(key),
606                }
607            }
608            Self {
609                key,
610                previous,
611                _guard: guard,
612            }
613        }
614    }
615
616    impl Drop for EnvVarGuard {
617        fn drop(&mut self) {
618            // SAFETY: the mutex guard is held until after restoration, keeping
619            // this crate's env-var tests serialized around AICX_ALLOW_TMP.
620            unsafe {
621                match &self.previous {
622                    Some(previous) => std::env::set_var(self.key, previous),
623                    None => std::env::remove_var(self.key),
624                }
625            }
626        }
627    }
628
629    #[test]
630    fn test_contains_traversal() {
631        assert!(contains_traversal("../etc/passwd"));
632        assert!(contains_traversal("foo/../bar"));
633        assert!(contains_traversal("path\0with\0nulls"));
634        assert!(contains_traversal("line\nbreak"));
635        assert!(!contains_traversal("/normal/path"));
636        assert!(!contains_traversal("simple_name"));
637        assert!(!contains_traversal("./relative/path"));
638    }
639
640    #[test]
641    fn test_contains_traversal_does_not_flag_three_dot_folder() {
642        // Regression: a literal `...` directory name (yes, it happens — we had
643        // a broken ingest that wrote `~/.aicx/store/...`) is NOT path traversal
644        // and must not nuke the entire corpus iteration.
645        assert!(!contains_traversal("..."));
646        assert!(!contains_traversal("/Users/foo/.aicx/store/..."));
647        assert!(!contains_traversal("foo/.../bar"));
648    }
649
650    #[test]
651    fn test_contains_traversal_does_not_flag_dot_dot_inside_name() {
652        // `..` as a substring inside a normal component is fine; only a
653        // standalone `..` component is genuine traversal.
654        assert!(!contains_traversal("foo..bar"));
655        assert!(!contains_traversal("a..b/c"));
656        assert!(!contains_traversal("normal..text"));
657        assert!(!contains_traversal("/srv/a..b/c"));
658    }
659
660    #[test]
661    fn test_contains_traversal_carriage_return() {
662        assert!(contains_traversal("path\rwith\rcr"));
663    }
664
665    #[test]
666    fn test_tmp_allowlist_hybrid_policy() {
667        {
668            let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
669            assert!(temp_allowlist_enabled_for_runtime(true, false));
670            assert!(temp_allowlist_enabled_for_runtime(false, true));
671            assert!(!temp_allowlist_enabled_for_runtime(false, false));
672        }
673
674        {
675            let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("1"));
676            assert!(temp_allowlist_enabled_for_runtime(false, false));
677        }
678
679        {
680            let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("true"));
681            assert!(!temp_allowlist_enabled_for_runtime(false, false));
682        }
683    }
684
685    #[test]
686    fn test_cargo_test_exe_detection_accepts_custom_target_dir_layout() {
687        let path =
688            Path::new("/Users/runner/work/cache/aicx-macos/debug/deps/aicx-0b1797b9ba8904ee");
689        assert!(is_cargo_test_exe_path(path));
690    }
691
692    #[test]
693    fn test_cargo_test_exe_detection_rejects_normal_binary_paths() {
694        let path = Path::new("/Users/runner/work/aicx/target/debug/aicx");
695        assert!(!is_cargo_test_exe_path(path));
696    }
697
698    #[test]
699    fn test_current_user_allowed_bases_are_accepted() {
700        for base in current_user_allowed_bases().expect("current user dirs") {
701            assert!(
702                is_under_allowed_base(&base.join("aicx-sanitize-test")).expect("allowlist check"),
703                "current user base should be allowed: {}",
704                base.display()
705            );
706        }
707    }
708
709    #[cfg(target_os = "macos")]
710    #[test]
711    fn test_macos_other_user_path_rejected() {
712        let path = Path::new("/Users/other_user/Documents/secret.txt");
713        assert!(
714            !is_under_allowed_base(path).expect("allowlist check"),
715            "macOS /Users allowlist must not generalize across users"
716        );
717    }
718
719    #[test]
720    fn test_validate_read_path_existing() {
721        let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
722        let tmp = std::env::temp_dir().join("ai-ctx-san-test-read");
723        let _ = fs::remove_dir_all(&tmp);
724        fs::create_dir_all(&tmp).unwrap();
725        let test_file = tmp.join("test.txt");
726        fs::write(&test_file, "test").unwrap();
727
728        let result = validate_read_path(&test_file);
729        assert!(result.is_ok(), "Failed: {:?}", result);
730
731        let _ = fs::remove_dir_all(&tmp);
732    }
733
734    #[test]
735    fn test_validate_read_path_traversal() {
736        let bad = Path::new("/tmp/../../../etc/passwd");
737        assert!(validate_read_path(bad).is_err());
738    }
739
740    #[test]
741    fn test_validate_read_path_nonexistent() {
742        let missing = Path::new("/tmp/ai-ctx-nonexistent-12345");
743        assert!(validate_read_path(missing).is_err());
744    }
745
746    #[test]
747    fn test_validate_write_path_new() {
748        let tmp = std::env::temp_dir().join("ai-ctx-san-test-write");
749        let _ = fs::create_dir_all(&tmp);
750        let new_file = tmp.join("new.txt");
751        let result = validate_write_path(&new_file);
752        assert!(result.is_ok(), "Failed: {:?}", result);
753        let _ = fs::remove_dir_all(&tmp);
754    }
755
756    #[test]
757    fn test_validate_write_path_traversal() {
758        let bad = Path::new("/tmp/../../../etc/evil.txt");
759        assert!(validate_write_path(bad).is_err());
760    }
761
762    #[test]
763    fn test_validate_write_path_rejects_non_allowed_ancestor() {
764        let bad = Path::new("/etc/ai-contexters-test/nope/file.txt");
765        assert!(validate_write_path(bad).is_err());
766    }
767
768    #[test]
769    fn test_validate_write_path_relative_with_missing_parents() {
770        let nested = Path::new("target/ai-ctx-sanitize-new/subdir/new.txt");
771        assert!(validate_write_path(nested).is_ok());
772    }
773
774    #[test]
775    fn test_validate_dir_path() {
776        let tmp = std::env::temp_dir();
777        assert!(validate_dir_path(&tmp).is_ok());
778    }
779
780    #[test]
781    fn test_open_file_validated() {
782        let tmp = std::env::temp_dir().join("ai-ctx-san-open-file");
783        let _ = fs::remove_dir_all(&tmp);
784        fs::create_dir_all(&tmp).unwrap();
785        let test_file = tmp.join("test.txt");
786        fs::write(&test_file, "hello").unwrap();
787
788        let mut opened = open_file_validated(&test_file).unwrap();
789        let mut content = String::new();
790        use std::io::Read as _;
791        opened.read_to_string(&mut content).unwrap();
792        assert_eq!(content, "hello");
793
794        let _ = fs::remove_dir_all(&tmp);
795    }
796
797    #[test]
798    fn test_read_to_string_validated() {
799        let tmp = std::env::temp_dir().join("ai-ctx-san-read-string");
800        let _ = fs::remove_dir_all(&tmp);
801        fs::create_dir_all(&tmp).unwrap();
802        let test_file = tmp.join("test.txt");
803        fs::write(&test_file, "hello").unwrap();
804
805        let content = read_to_string_validated(&test_file).unwrap();
806        assert_eq!(content, "hello");
807
808        let _ = fs::remove_dir_all(&tmp);
809    }
810
811    #[test]
812    fn test_create_file_validated() {
813        let tmp = std::env::temp_dir().join("ai-ctx-san-create-file");
814        let _ = fs::remove_dir_all(&tmp);
815        fs::create_dir_all(&tmp).unwrap();
816        let test_file = tmp.join("test.txt");
817
818        let mut created = create_file_validated(&test_file).unwrap();
819        use std::io::Write as _;
820        created.write_all(b"hello").unwrap();
821        drop(created);
822
823        let content = fs::read_to_string(&test_file).unwrap();
824        assert_eq!(content, "hello");
825
826        let _ = fs::remove_dir_all(&tmp);
827    }
828
829    #[test]
830    fn test_read_dir_validated() {
831        let tmp = std::env::temp_dir().join("ai-ctx-san-read-dir");
832        let _ = fs::remove_dir_all(&tmp);
833        fs::create_dir_all(&tmp).unwrap();
834        fs::write(tmp.join("a.txt"), "a").unwrap();
835
836        let entries = read_dir_validated(&tmp)
837            .unwrap()
838            .filter_map(|entry| entry.ok())
839            .count();
840        assert_eq!(entries, 1);
841
842        let _ = fs::remove_dir_all(&tmp);
843    }
844
845    #[test]
846    fn test_sanitize_strips_nul_byte_and_warns() {
847        let sanitized = sanitize_chunk_content("abc\0def\0");
848        assert_eq!(sanitized.text, "abcdef");
849        assert_eq!(
850            sanitized.warnings,
851            vec![
852                ContentSanitizationWarning::NullByteStripped(3),
853                ContentSanitizationWarning::NullByteStripped(7),
854            ]
855        );
856    }
857
858    #[test]
859    fn test_sanitize_normalizes_crlf_to_lf() {
860        let sanitized = sanitize_chunk_content("one\r\ntwo\rthree\nfour");
861        assert_eq!(sanitized.text, "one\ntwo\nthree\nfour");
862        assert!(sanitized.warnings.is_empty());
863    }
864
865    #[test]
866    fn test_sanitize_preserves_unicode_emoji() {
867        let sanitized = sanitize_chunk_content("ship it 🚀");
868        assert_eq!(sanitized.text, "ship it 🚀");
869        assert!(sanitized.warnings.is_empty());
870    }
871
872    #[test]
873    fn test_sanitize_preserves_polish_diacritics_nfc() {
874        let input = "Zażółć gęślą jaźń";
875        let sanitized = sanitize_chunk_content(input);
876        assert_eq!(sanitized.text, input);
877        assert!(sanitized.warnings.is_empty());
878    }
879
880    #[test]
881    fn test_sanitize_bidi_override_warns_but_does_not_strip() {
882        let input = "safe \u{202E}txt";
883        let sanitized = sanitize_chunk_content(input);
884        assert_eq!(sanitized.text, input);
885        assert_eq!(
886            sanitized.warnings,
887            vec![ContentSanitizationWarning::BidiOverride('\u{202E}', 5)]
888        );
889    }
890
891    #[test]
892    fn test_sanitize_zero_width_warns_but_does_not_strip() {
893        let input = "zero\u{200B}width";
894        let sanitized = sanitize_chunk_content(input);
895        assert_eq!(sanitized.text, input);
896        assert_eq!(
897            sanitized.warnings,
898            vec![ContentSanitizationWarning::ZeroWidth('\u{200B}', 4)]
899        );
900    }
901
902    #[test]
903    fn test_safe_agent_name_valid() {
904        assert_eq!(safe_agent_name("claude").unwrap(), "claude");
905        assert_eq!(safe_agent_name("codex").unwrap(), "codex");
906        assert_eq!(safe_agent_name("gemini").unwrap(), "gemini");
907        assert_eq!(safe_agent_name("junie").unwrap(), "junie");
908        assert_eq!(safe_agent_name("codescribe").unwrap(), "codescribe");
909        assert_eq!(safe_agent_name("operator-md").unwrap(), "operator-md");
910    }
911
912    #[test]
913    fn test_safe_agent_name_rejects_unknown() {
914        assert!(safe_agent_name("rm").is_err());
915        assert!(safe_agent_name("bash").is_err());
916        assert!(safe_agent_name("claude; rm -rf /").is_err());
917    }
918
919    #[test]
920    fn test_safe_project_name_valid() {
921        assert!(safe_project_name("my-project").is_ok());
922        assert!(safe_project_name("lbrx-services").is_ok());
923        assert!(safe_project_name("CodeScribe").is_ok());
924    }
925}
926
927// ============================================================================
928// Query normalization (PL/EN diacritics + case folding)
929// ============================================================================
930
931/// Normalize text for fuzzy matching: NFC + lowercase + strip Polish diacritics.
932///
933/// NFC canonical composition is applied first so NFD-stored variants (e.g.
934/// `o` + combining acute) collapse to the same code points as NFC-stored
935/// composed variants (`ó`) before diacritic mapping. Without this step,
936/// `źródło` typed via NFD on one platform would refuse to match the same
937/// word typed via NFC on another.
938///
939/// Maps: ą→a, ć→c, ę→e, ł→l, ń→n, ó→o, ś→s, ź→z, ż→z
940/// Enables "wdrozenie" to match "wdrożenie", "zrodlo" to match "źródło", etc.
941pub fn normalize_query(text: &str) -> String {
942    use unicode_normalization::UnicodeNormalization;
943    text.nfc()
944        .map(|c| match c {
945            'Ą' | 'ą' => 'a',
946            'Ć' | 'ć' => 'c',
947            'Ę' | 'ę' => 'e',
948            'Ł' | 'ł' => 'l',
949            'Ń' | 'ń' => 'n',
950            'Ó' | 'ó' => 'o',
951            'Ś' | 'ś' => 's',
952            'Ź' | 'ź' | 'Ż' | 'ż' => 'z',
953            _ => c,
954        })
955        .collect::<String>()
956        .to_lowercase()
957}
958
959// ============================================================================
960// Self-echo filtering (prevents feedback loops)
961// ============================================================================
962
963/// MCP tool names + dashboard HTTP routes that indicate aicx's own
964/// operational traffic. These are the "stable surface" patterns; CLI
965/// subcommand patterns are derived from [`CLI_SUBCOMMAND_NAMES`] and the
966/// JSON-RPC catch-all lives in its own constant so each axis stays
967/// auditable.
968///
969/// Retired MCP tool names stay here so historical traces remain filterable.
970const STABLE_SELF_ECHO_PATTERNS: &[&str] = &[
971    // MCP tool calls (current + retired names so historical traces remain filterable)
972    "aicx_search",
973    "aicx_read",
974    "aicx_rank",
975    "aicx_refs",
976    "aicx_store",
977    // Dashboard API calls
978    "/api/search/fuzzy",
979    "/api/search/semantic",
980    "/api/search/cross",
981    "/api/health",
982    "/api/regenerate",
983    "/api/status",
984];
985
986/// Generic MCP JSON-RPC catch-all. Any line containing a raw JSON-RPC 2.0
987/// envelope is, by construction, aicx's own protocol traffic (or another
988/// MCP client's, which is still recycled context — not original signal).
989/// This subsumes the previous per-method patterns
990/// (`"method":"tools/call"`, `"method":"tools/list"`,
991/// `"method":"initialize"`) — any new method name lands here automatically
992/// without recompile. L36 (c).
993const MCP_JSONRPC_CATCH_ALL: &str = "\"jsonrpc\":\"2.0\"";
994
995/// Retired CLI subcommand names. Kept here so historical traces (operator
996/// shells, logged transcripts from older aicx versions) still classify as
997/// self-echo. Parallel to retired MCP tool names in
998/// `STABLE_SELF_ECHO_PATTERNS`.
999const RETIRED_CLI_SUBCOMMANDS: &[&str] = &[
1000    // `aicx rank -p ...` predated the unified `intents`/`search` surface.
1001    "rank",
1002    // `aicx gemini` / `aicx junie` were single-source extractors before
1003    // they were folded into `aicx all`/`aicx store --agent <name>`.
1004    "gemini", "junie",
1005];
1006
1007/// Canonical, kebab-case list of every `Commands::*` variant in `src/main.rs`.
1008///
1009/// **Single source of truth.** When a new subcommand is added (or renamed)
1010/// in the binary's `Commands` enum, update this constant — drift is caught
1011/// by `assert_cli_subcommand_names_match_clap` in `src/main/tests.rs`,
1012/// which walks `Cli::command().get_subcommands()` and fails the test if
1013/// the two lists disagree.
1014///
1015/// Entries match the names that clap emits at runtime (so `MigrateIntentSchema`
1016/// → `"migrate-intent-schema"`, `DashboardServeLegacy` → `"dashboard-serve"`
1017/// via the explicit `#[command(name = ...)]` override, etc.) plus every
1018/// subcommand alias (so `Sessions` alias `"session"` is listed too —
1019/// operators type aliases at the shell, and those invocations must be
1020/// filtered just like the canonical names).
1021///
1022/// L36 (b): used to materialize `aicx <subcommand>` self-echo patterns
1023/// for every variant, replacing the prior hand-curated subset.
1024pub const CLI_SUBCOMMAND_NAMES: &[&str] = &[
1025    "all",
1026    "claims",
1027    "clarify",
1028    "claude",
1029    "codex",
1030    "config",
1031    "conversations",
1032    "corpus",
1033    "dashboard",
1034    "dashboard-serve",
1035    "doctor",
1036    "extract",
1037    "health",
1038    "index",
1039    "ingest",
1040    "init",
1041    "intents",
1042    "list",
1043    "migrate",
1044    "migrate-intent-schema",
1045    "read",
1046    "refs",
1047    "reports",
1048    "reports-extractor",
1049    "results",
1050    "search",
1051    "serve",
1052    "session",
1053    "sessions",
1054    "sources",
1055    "state",
1056    "steer",
1057    "store",
1058    "tail",
1059    "warmup",
1060    "wizard",
1061];
1062
1063/// Build the full self-echo pattern list (stable + JSON-RPC catch-all +
1064/// CLI subcommand patterns + caller-supplied extras). Pure function; no
1065/// I/O, no globals — keeps the public surface easy to unit-test.
1066///
1067/// L36 (b)+(c)+(d): consolidates every axis of self-echo detection
1068/// (MCP tools, dashboard HTTP, JSON-RPC envelopes, CLI subcommands,
1069/// operator config extras) into one materialized list.
1070fn build_self_echo_patterns(extra: &[String]) -> Vec<String> {
1071    let mut patterns: Vec<String> = Vec::with_capacity(
1072        STABLE_SELF_ECHO_PATTERNS.len()
1073            + CLI_SUBCOMMAND_NAMES.len()
1074            + RETIRED_CLI_SUBCOMMANDS.len()
1075            + extra.len()
1076            + 1,
1077    );
1078    for pat in STABLE_SELF_ECHO_PATTERNS {
1079        patterns.push((*pat).to_string());
1080    }
1081    patterns.push(MCP_JSONRPC_CATCH_ALL.to_string());
1082    for name in CLI_SUBCOMMAND_NAMES.iter().chain(RETIRED_CLI_SUBCOMMANDS) {
1083        patterns.push(format!("aicx {name}"));
1084    }
1085    for pat in extra {
1086        let trimmed = pat.trim();
1087        if !trimmed.is_empty() {
1088            patterns.push(trimmed.to_string());
1089        }
1090    }
1091    patterns
1092}
1093
1094/// Process-cached lowercase pattern list. Hardcoded patterns plus
1095/// `[extraction].extra_self_echo_patterns` from `~/.aicx/config.toml`
1096/// (L36 (d)). Initialised on first call to [`is_self_echo`].
1097static SELF_ECHO_PATTERNS_CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1098
1099/// Returns the active self-echo pattern list, lower-cased for
1100/// case-insensitive matching. First call reads config; subsequent calls
1101/// return the cached vec.
1102fn active_self_echo_patterns() -> &'static [String] {
1103    SELF_ECHO_PATTERNS_CACHE.get_or_init(|| {
1104        let extras = load_extra_self_echo_patterns_from_config();
1105        build_self_echo_patterns(&extras)
1106            .into_iter()
1107            .map(|p| p.to_lowercase())
1108            .collect()
1109    })
1110}
1111
1112/// Read `[extraction].extra_self_echo_patterns` from
1113/// `$AICX_HOME/config.toml` (or `~/.aicx/config.toml`). Mirrors the
1114/// AICX_HOME resolution used by `ProjectHashRegistry::load_default` so
1115/// the parser crate stays self-contained (no embedder-crate coupling).
1116///
1117/// Returns an empty vec on any failure — missing file, parse error, or
1118/// schema mismatch. Treating "no config" and "broken config" identically
1119/// is intentional: a typo in the operator's TOML should never disable
1120/// the hardcoded patterns that protect extraction from feedback loops.
1121///
1122/// L36 (d).
1123fn load_extra_self_echo_patterns_from_config() -> Vec<String> {
1124    #[derive(serde::Deserialize, Default)]
1125    struct ExtractionFile {
1126        #[serde(default)]
1127        extraction: Option<ExtractionSection>,
1128    }
1129    #[derive(serde::Deserialize, Default)]
1130    struct ExtractionSection {
1131        #[serde(default)]
1132        extra_self_echo_patterns: Vec<String>,
1133    }
1134
1135    let base = std::env::var_os("AICX_HOME")
1136        .map(PathBuf::from)
1137        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".aicx")));
1138    let Some(base) = base else {
1139        return Vec::new();
1140    };
1141    let path = base.join("config.toml");
1142    let raw = match read_to_string_validated(&path) {
1143        Ok(raw) => raw,
1144        Err(_) => return Vec::new(),
1145    };
1146    let parsed: ExtractionFile = match toml::from_str(&raw) {
1147        Ok(parsed) => parsed,
1148        Err(_) => return Vec::new(),
1149    };
1150    parsed
1151        .extraction
1152        .map(|section| section.extra_self_echo_patterns)
1153        .unwrap_or_default()
1154        .into_iter()
1155        .map(|p| p.trim().to_string())
1156        .filter(|p| !p.is_empty())
1157        .collect()
1158}
1159
1160/// Sentinel brackets for aicx read blocks injected by vc-init / vc-agents.
1161/// Content between these markers is recycled context, not original signal.
1162const AICX_READ_BEGIN: &str = "【aicx:read】";
1163const AICX_READ_END: &str = "【/aicx:read】";
1164
1165/// Returns true if a message is aicx operational self-echo that should be
1166/// filtered from extraction to prevent feedback loops.
1167///
1168/// A message is self-echo if >50% of its non-empty lines match patterns,
1169/// excluding lines inside 【aicx:read】...【/aicx:read】 blocks (which are
1170/// counted as echo unconditionally).
1171pub fn is_self_echo(message: &str) -> bool {
1172    let lines: Vec<&str> = message
1173        .lines()
1174        .map(str::trim)
1175        .filter(|l| !l.is_empty())
1176        .collect();
1177
1178    if lines.is_empty() {
1179        return false;
1180    }
1181
1182    let mut echo_lines = 0usize;
1183    let mut inside_aicx_block = false;
1184
1185    for line in &lines {
1186        if line.contains(AICX_READ_BEGIN) {
1187            inside_aicx_block = true;
1188            echo_lines += 1;
1189            continue;
1190        }
1191        if line.contains(AICX_READ_END) {
1192            inside_aicx_block = false;
1193            echo_lines += 1;
1194            continue;
1195        }
1196        if inside_aicx_block {
1197            echo_lines += 1;
1198            continue;
1199        }
1200        let lower = line.to_lowercase();
1201        if active_self_echo_patterns()
1202            .iter()
1203            .any(|pat| lower.contains(pat))
1204        {
1205            echo_lines += 1;
1206        }
1207    }
1208
1209    // Message is self-echo only if a strict majority of lines match.
1210    echo_lines > 0 && echo_lines * 2 > lines.len()
1211}
1212
1213/// Filter a vec of timeline entries, removing self-echo messages.
1214pub fn filter_self_echo<T>(entries: Vec<T>, get_message: impl Fn(&T) -> &str) -> Vec<T> {
1215    entries
1216        .into_iter()
1217        .filter(|e| !is_self_echo(get_message(e)))
1218        .collect()
1219}
1220
1221#[cfg(test)]
1222mod echo_tests {
1223    use super::*;
1224
1225    #[test]
1226    fn test_normal_message_not_echo() {
1227        assert!(!is_self_echo("Fix the login regression in auth middleware"));
1228        assert!(!is_self_echo("Decision: use per-chunk scoring"));
1229        assert!(!is_self_echo("TODO: add tests for edge cases"));
1230    }
1231
1232    #[test]
1233    fn test_search_call_is_echo() {
1234        assert!(is_self_echo(
1235            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"aicx_search","arguments":{"query":"deploy vistacare"}}}"#
1236        ));
1237    }
1238
1239    #[test]
1240    fn test_api_call_is_echo() {
1241        assert!(is_self_echo(
1242            r#"curl -s "http://127.0.0.1:8033/api/search/fuzzy?q=deploy+vistacare&limit=3""#
1243        ));
1244    }
1245
1246    #[test]
1247    fn test_cli_self_invocation_is_echo() {
1248        assert!(is_self_echo("aicx all -H 24 --emit none"));
1249        assert!(is_self_echo("aicx store -H 24 --full-rescan"));
1250        assert!(is_self_echo("aicx store --hours 24"));
1251        assert!(is_self_echo("aicx rank -p ai-contexters -H 72 --strict"));
1252        assert!(is_self_echo(
1253            "aicx dashboard --generate-html -p ai-contexters -H 24"
1254        ));
1255        assert!(is_self_echo(
1256            "aicx reports --repo ai-contexters --workflow marbles"
1257        ));
1258    }
1259
1260    #[test]
1261    fn test_mention_in_larger_message_not_echo() {
1262        // Mere mention of aicx in a discussion should NOT be filtered
1263        let msg = "We should add aicx_search to the MCP server.\n\
1264                   The architecture looks clean.\n\
1265                   Let's proceed with implementation.\n\
1266                   Decision: expose 4 tools via rmcp.";
1267        assert!(!is_self_echo(msg));
1268    }
1269
1270    #[test]
1271    fn test_self_echo_exactly_half_is_not_majority() {
1272        let msg = "aicx all -H 24 --emit none\n\
1273                   Decision: preserve real operator signal\n\
1274                   aicx store -H 24 --full-rescan\n\
1275                   Root cause: threshold was too wide";
1276        assert!(!is_self_echo(msg));
1277    }
1278
1279    #[test]
1280    fn test_self_echo_just_above_half_is_echo() {
1281        let msg = "aicx all -H 24 --emit none\n\
1282                   Decision: preserve real operator signal\n\
1283                   aicx store -H 24 --full-rescan\n\
1284                   Root cause: threshold was too wide\n\
1285                   aicx refs -H 24";
1286        assert!(is_self_echo(msg));
1287    }
1288
1289    #[test]
1290    fn test_self_echo_just_below_half_is_not_echo() {
1291        let msg = "aicx all -H 24 --emit none\n\
1292                   Decision: preserve real operator signal\n\
1293                   aicx store -H 24 --full-rescan\n\
1294                   Root cause: threshold was too wide\n\
1295                   Follow-up: add focused coverage";
1296        assert!(!is_self_echo(msg));
1297    }
1298
1299    // -----------------------------------------------------------------------
1300    // L36 (b): CLI subcommand coverage — every Commands::* variant in the
1301    // binary surfaces as an `aicx <subcommand>` pattern. Previously only a
1302    // handful of subcommands (all/claude/codex/store/rank/refs/dashboard/...)
1303    // were hardcoded; common operator invocations like `aicx state --info`,
1304    // `aicx doctor`, `aicx index --dry-run`, etc. leaked through self-echo
1305    // and re-contaminated extractions. These tests pin the regression so
1306    // that whenever a new subcommand lands in `Commands`, the matching
1307    // pattern is added to `CLI_SUBCOMMAND_NAMES`.
1308    // -----------------------------------------------------------------------
1309
1310    #[test]
1311    fn test_state_subcommand_is_echo() {
1312        // `aicx state --info` is a routine operator probe; it must not leak
1313        // into chunked extractions as substantive content.
1314        let msg = "aicx state --info\n\
1315                   aicx state --reset\n\
1316                   aicx state -p vetcoders/Vista";
1317        assert!(
1318            is_self_echo(msg),
1319            "`aicx state` invocations should be classified as self-echo"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_doctor_subcommand_is_echo() {
1325        let msg = "aicx doctor --oracle\n\
1326                   aicx doctor --rebuild-steer-index\n\
1327                   aicx doctor --check-dedup";
1328        assert!(
1329            is_self_echo(msg),
1330            "`aicx doctor` invocations should be classified as self-echo"
1331        );
1332    }
1333
1334    // -----------------------------------------------------------------------
1335    // L36 (d): config-driven `[extraction].extra_self_echo_patterns`.
1336    //
1337    // The pure builder (`build_self_echo_patterns`) is tested here because
1338    // the cached top-level helper reads `~/.aicx/config.toml` via a process
1339    // OnceLock that can't be safely re-initialised mid-test. The integration
1340    // path is covered indirectly: the loader is a thin TOML read whose
1341    // contract is "missing file or parse error → empty vec" (also asserted
1342    // below by `build_self_echo_patterns` with an empty extras slice).
1343    // -----------------------------------------------------------------------
1344
1345    #[test]
1346    fn test_build_self_echo_patterns_includes_config_extras() {
1347        let extras = vec![
1348            "custom-tool-name".to_string(),
1349            "  /api/internal/foo  ".to_string(), // exercises trim
1350            "".to_string(),                      // empty entries silently dropped
1351        ];
1352        let patterns = build_self_echo_patterns(&extras);
1353
1354        assert!(
1355            patterns.iter().any(|p| p == "custom-tool-name"),
1356            "non-empty extra pattern must appear verbatim in the materialized list"
1357        );
1358        assert!(
1359            patterns.iter().any(|p| p == "/api/internal/foo"),
1360            "extra patterns must be trimmed of surrounding whitespace"
1361        );
1362        assert!(
1363            !patterns.iter().any(|p| p.is_empty()),
1364            "empty extras must be filtered out"
1365        );
1366
1367        // Baseline coverage: hardcoded + CLI + retired + catch-all all still present.
1368        assert!(patterns.iter().any(|p| p == "aicx_search"));
1369        assert!(patterns.iter().any(|p| p == "aicx doctor"));
1370        assert!(patterns.iter().any(|p| p == "aicx rank"));
1371        assert!(patterns.iter().any(|p| p == MCP_JSONRPC_CATCH_ALL));
1372    }
1373
1374    #[test]
1375    fn test_build_self_echo_patterns_empty_extras_matches_baseline() {
1376        // L36 (d) safety property: a missing or empty `[extraction]`
1377        // section must not subtract hardcoded patterns.
1378        let baseline = build_self_echo_patterns(&[]);
1379        assert!(
1380            baseline.iter().any(|p| p == "aicx_search"),
1381            "MCP tool names must survive an empty extras list"
1382        );
1383        assert!(
1384            baseline.iter().any(|p| p == "aicx doctor"),
1385            "CLI subcommand patterns must survive an empty extras list"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_load_extra_self_echo_patterns_from_config_reads_extraction_section() {
1391        use std::fs;
1392        use std::sync::atomic::{AtomicUsize, Ordering};
1393
1394        static UNIQ: AtomicUsize = AtomicUsize::new(0);
1395        let dir = std::env::temp_dir().join(format!(
1396            "aicx-extraction-config-{}-{}",
1397            std::process::id(),
1398            UNIQ.fetch_add(1, Ordering::SeqCst)
1399        ));
1400        fs::create_dir_all(&dir).unwrap();
1401        fs::write(
1402            dir.join("config.toml"),
1403            r#"
1404[extraction]
1405extra_self_echo_patterns = [
1406    "operator-marker-alpha",
1407    "  /api/operator/beta  ",
1408    "",
1409]
1410"#,
1411        )
1412        .unwrap();
1413
1414        // Drive the loader directly via $AICX_HOME so we don't touch the
1415        // OnceLock-cached top-level helper. Set+restore the env var to keep
1416        // other tests deterministic.
1417        let prev = std::env::var_os("AICX_HOME");
1418        // SAFETY: tests that touch the same env var must be serialized by
1419        // the test runner; aicx test suite already documents this as a
1420        // single-threaded constraint. See `crates/aicx-parser/tests/`.
1421        // Marked unsafe because std::env::set_var is unsafe in Rust 2024.
1422        unsafe {
1423            std::env::set_var("AICX_HOME", &dir);
1424        }
1425        let loaded = load_extra_self_echo_patterns_from_config();
1426        unsafe {
1427            match prev {
1428                Some(v) => std::env::set_var("AICX_HOME", v),
1429                None => std::env::remove_var("AICX_HOME"),
1430            }
1431        }
1432
1433        assert!(
1434            loaded.iter().any(|p| p == "operator-marker-alpha"),
1435            "loader must surface verbatim operator-supplied pattern; got {loaded:?}"
1436        );
1437        assert!(
1438            loaded.iter().any(|p| p == "/api/operator/beta"),
1439            "loader must trim whitespace; got {loaded:?}"
1440        );
1441        assert!(
1442            !loaded.iter().any(|p| p.is_empty()),
1443            "loader must drop empty entries; got {loaded:?}"
1444        );
1445
1446        let _ = fs::remove_dir_all(&dir);
1447    }
1448
1449    #[test]
1450    fn test_load_extra_self_echo_patterns_returns_empty_when_no_config() {
1451        use std::sync::atomic::{AtomicUsize, Ordering};
1452
1453        static UNIQ: AtomicUsize = AtomicUsize::new(0);
1454        let nonexistent = std::env::temp_dir().join(format!(
1455            "aicx-extraction-config-nope-{}-{}",
1456            std::process::id(),
1457            UNIQ.fetch_add(1, Ordering::SeqCst)
1458        ));
1459        // Do NOT create the directory — loader must tolerate the absent path.
1460
1461        let prev = std::env::var_os("AICX_HOME");
1462        unsafe {
1463            std::env::set_var("AICX_HOME", &nonexistent);
1464        }
1465        let loaded = load_extra_self_echo_patterns_from_config();
1466        unsafe {
1467            match prev {
1468                Some(v) => std::env::set_var("AICX_HOME", v),
1469                None => std::env::remove_var("AICX_HOME"),
1470            }
1471        }
1472
1473        assert!(
1474            loaded.is_empty(),
1475            "missing config must yield empty extras, not panic; got {loaded:?}"
1476        );
1477    }
1478
1479    #[test]
1480    fn test_mcp_jsonrpc_catch_all_matches_unknown_method() {
1481        // L36 (c): previously only `"method":"tools/call"`,
1482        // `"method":"tools/list"`, and `"method":"initialize"` matched.
1483        // Any other JSON-RPC envelope (notifications, future methods,
1484        // resource/prompt endpoints) leaked through. The generic
1485        // `"jsonrpc":"2.0"` catch-all subsumes the entire surface so new
1486        // MCP method names are filtered without recompile.
1487        assert!(
1488            is_self_echo(
1489                r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#
1490            ),
1491            "unknown JSON-RPC method should be caught by the generic catch-all"
1492        );
1493        assert!(
1494            is_self_echo(r#"{"jsonrpc":"2.0","method":"resources/list","params":{}}"#),
1495            "resources/list JSON-RPC must be filtered as self-echo"
1496        );
1497        assert!(
1498            is_self_echo(
1499                r#"{"jsonrpc":"2.0","id":42,"result":{"contents":[{"uri":"aicx://chunk/abc"}]}}"#
1500            ),
1501            "JSON-RPC result envelope (no method name at all) must still classify as self-echo"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_corpus_index_warmup_wizard_config_subcommands_are_echo() {
1507        // Aggregate test — every subcommand below was historically missing
1508        // from SELF_ECHO_PATTERNS. Auto-derivation from `Commands::*` closes
1509        // the gap.
1510        for line in [
1511            "aicx corpus --audit",
1512            "aicx index --dry-run",
1513            "aicx warmup",
1514            "aicx wizard",
1515            "aicx config show",
1516            "aicx steer --run-id abc",
1517            "aicx tail --follow",
1518            "aicx ingest --source loct-context-pack",
1519            "aicx migrate --dry-run",
1520            "aicx sources audit",
1521            "aicx serve --transport stdio",
1522            "aicx extract --session abc --agent claude",
1523            "aicx read /some/path",
1524            "aicx search hello",
1525            "aicx refs -H 24",
1526            "aicx reports --workflow marbles",
1527            "aicx intents -H 720",
1528            "aicx conversations --out-dir /tmp",
1529            "aicx health",
1530            "aicx list",
1531            "aicx claude -H 24",
1532            "aicx codex -H 24",
1533            "aicx all -H 24",
1534            "aicx store -H 24",
1535        ] {
1536            assert!(
1537                is_self_echo(line),
1538                "`{line}` should be classified as self-echo (Commands variant missing from pattern list?)"
1539            );
1540        }
1541    }
1542}
1543
1544#[cfg(test)]
1545mod normalize_tests {
1546    use super::*;
1547
1548    #[test]
1549    fn test_normalize_query_strips_diacritics() {
1550        assert_eq!(normalize_query("wdrożenie"), "wdrozenie");
1551        assert_eq!(normalize_query("źródło ŁĄCZNOŚCI"), "zrodlo lacznosci");
1552        assert_eq!(normalize_query("Deploy Vista"), "deploy vista");
1553        assert_eq!(normalize_query("ąćęłńóśźż"), "acelnoszz");
1554    }
1555
1556    #[test]
1557    fn test_normalize_query_unifies_nfc_and_nfd_inputs() {
1558        // D-11: same word stored in NFC ("ó" composed) vs NFD ("o" + combining
1559        // acute U+0301) must normalize to the same key. Without NFC pre-pass,
1560        // NFD lookups would not survive the diacritic mapping table.
1561        use unicode_normalization::UnicodeNormalization;
1562        let composed = "źródło";
1563        let decomposed: String = composed.nfd().collect();
1564        assert_ne!(
1565            composed, decomposed,
1566            "test pre-condition: NFC and NFD forms must differ at the byte level"
1567        );
1568        assert_eq!(
1569            normalize_query(composed),
1570            normalize_query(&decomposed),
1571            "NFC pre-pass must collapse pre-composed and decomposed diacritics"
1572        );
1573        assert_eq!(normalize_query(&decomposed), "zrodlo");
1574    }
1575
1576    #[test]
1577    fn test_safe_project_name_rejects_bad() {
1578        assert!(safe_project_name("../etc").is_err());
1579        assert!(safe_project_name("foo/bar").is_err());
1580        assert!(safe_project_name("").is_err());
1581        assert!(safe_project_name("foo\0bar").is_err());
1582    }
1583}