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