Skip to main content

lean_ctx/tools/
ctx_edit.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::cache::SessionCache;
4use crate::core::tokens::count_tokens;
5// Shared TOCTOU-safe read→verify→atomic-write primitives (epic #1008): the same
6// audited boundary `ctx_patch` (anchored editing) builds on, so a fix protects
7// both tools. `verify_expected_preimage` stays here — it is `ctx_edit`-specific
8// (keyed on `EditParams`).
9use crate::tools::edit_io::{
10    FileFingerprint, FilePreimage, default_backup_path, ensure_preimage_still_matches,
11    read_preimage, system_time_to_millis, write_atomic_bytes_with_permissions,
12};
13
14/// Parameters for a file edit operation: path, old/new strings, and flags.
15pub struct EditParams {
16    pub path: String,
17    pub old_string: String,
18    pub new_string: String,
19    pub replace_all: bool,
20    pub create: bool,
21    /// Optional preimage guards. If provided, ctx_edit fails if the current file preimage differs.
22    pub expected_md5: Option<String>,
23    pub expected_size: Option<u64>,
24    pub expected_mtime_ms: Option<u64>,
25    /// Optional backup before writing.
26    pub backup: bool,
27    pub backup_path: Option<String>,
28    /// Emit bounded diff evidence (redacted) by default.
29    pub evidence: bool,
30    pub diff_max_lines: usize,
31    /// Reject invalid UTF-8 by default; allow lossy reads only when explicitly enabled.
32    pub allow_lossy_utf8: bool,
33}
34
35struct ReplaceArgs<'a> {
36    content: &'a str,
37    old_str: &'a str,
38    new_str: &'a str,
39    occurrences: usize,
40    replace_all: bool,
41    old_tokens: usize,
42    new_tokens: usize,
43}
44
45fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
46    if let Some(expected) = params.expected_size
47        && expected != pre.fp.size
48    {
49        return Err(format!(
50            "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
51            params.path, expected, pre.fp.size
52        ));
53    }
54    if let Some(expected) = params.expected_mtime_ms
55        && expected != pre.fp.mtime_ms
56    {
57        return Err(format!(
58            "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
59            params.path, expected, pre.fp.mtime_ms
60        ));
61    }
62    if let Some(expected) = params.expected_md5.as_deref()
63        && expected != pre.fp.md5
64    {
65        return Err(format!(
66            "ERROR: preimage mismatch for {}: expected_md5={}, actual_md5={}",
67            params.path, expected, pre.fp.md5
68        ));
69    }
70    Ok(())
71}
72
73/// Bounded, secret-redacted unified diff for edit evidence. `pub(crate)` so the
74/// anchored editor (`ctx_patch`) reuses the identical evidence format (#1008).
75pub(crate) fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
76    let diff = similar::TextDiff::from_lines(old, new)
77        .unified_diff()
78        .context_radius(3)
79        .header(label, label)
80        .to_string();
81    // Single source of truth for secret masking — `core::redaction` carries the
82    // GH #430 non-secret-literal guard (type annotations, `undefined`, …) and
83    // does not leak the value of generic long secrets like this duplicate once
84    // did. Keeping a second regex set here only invites drift.
85    let diff = crate::core::redaction::redact_text(&diff);
86
87    let mut out = String::new();
88    for (i, line) in diff.lines().enumerate() {
89        if i >= max_lines {
90            out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
91            break;
92        }
93        out.push_str(line);
94        out.push('\n');
95    }
96    out.trim_end_matches('\n').to_string()
97}
98
99/// A cache mutation that an edit needs *after* its disk I/O completes.
100///
101/// Decoupling the cache mutation from the I/O lets the MCP layer perform the
102/// (slow) file read/replace/write while holding only a cheap per-file lock, then
103/// touch the shared cache for a sub-millisecond instant — instead of holding the
104/// global cache write-lock across all disk I/O (the root cause of issue #320).
105pub enum CacheEffect {
106    /// No cache change required (e.g. the edit failed before writing).
107    None,
108    /// The file on disk changed; drop the stale cache entry.
109    Invalidate,
110    /// Auto-escalation re-read full content that should be stored and marked
111    /// as fully delivered.
112    StoreFull(String),
113}
114
115/// Performs a string replacement edit on a file with CRLF/LF and whitespace
116/// tolerance. Thin wrapper that runs the I/O and applies the resulting cache
117/// effect to `cache` in one shot (used by tests and any in-process caller that
118/// already holds the cache exclusively).
119pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
120    let last_mode = cache
121        .get(&params.path)
122        .map(|e| e.last_mode.clone())
123        .unwrap_or_default();
124    let (text, effect) = run_io(params, &last_mode);
125    record_outcome(params, &last_mode, &text, &effect);
126    apply_cache_effect(cache, &params.path, effect);
127    text
128}
129
130/// Quality loop (#494): classify the edit result and feed it into
131/// [`crate::core::edit_quality`]. Only two outcomes carry a compression
132/// signal: a clean replacement (success) and an `old_string` miss
133/// (failure — the body the agent quoted wasn't what's on disk). Parameter
134/// mistakes (empty/identical strings, preimage mismatch, missing file) and
135/// already-applied edits say nothing about the read mode and are skipped.
136pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
137    if params.create {
138        return;
139    }
140    let success = matches!(effect, CacheEffect::Invalidate);
141    let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
142        || (matches!(effect, CacheEffect::None)
143            && text.starts_with("ERROR: old_string not found")
144            && !text.contains("already"));
145    if success || not_found_failure {
146        crate::core::edit_quality::record_edit_outcome(&params.path, last_mode, success);
147    }
148    // Edit-efficiency channel (#1008): the str_replace baseline — output tokens
149    // actually paid reproducing `old_string`, and blind-retry round-trips.
150    // Separate from the read-gain ledger, never printed in tool output (#498).
151    if success {
152        crate::core::edit_metering::record_str_replace_success(
153            count_tokens(&params.old_string) as u64
154        );
155    } else if not_found_failure {
156        crate::core::edit_metering::record_str_replace_miss();
157    }
158}
159
160/// Applies a deferred [`CacheEffect`] to the session cache.
161pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
162    match effect {
163        CacheEffect::None => {}
164        CacheEffect::Invalidate => {
165            cache.invalidate(path);
166        }
167        CacheEffect::StoreFull(content) => {
168            cache.store(path, &content);
169            cache.mark_full_delivered(path);
170        }
171    }
172}
173
174/// Performs the full edit on disk **without** touching the session cache, and
175/// reports back the [`CacheEffect`] the caller should apply afterwards.
176///
177/// `last_mode` is the cache's recorded read mode for the path (used only to
178/// decide whether to auto-escalate on a not-found match); pass `""` when unknown.
179pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
180    let file_path = &params.path;
181
182    if params.create {
183        return handle_create(file_path, &params.new_string, params);
184    }
185
186    let cap = crate::core::limits::max_read_bytes();
187    let path = Path::new(file_path);
188    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
189        Ok(p) => p,
190        Err(e) => {
191            // File missing? Tell the agent whether it moved or the path is
192            // wrong, instead of a bare "cannot open" (#331 point 3).
193            if !path.exists() {
194                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
195                return (format!("{e}{hint}"), CacheEffect::None);
196            }
197            return (e, CacheEffect::None);
198        }
199    };
200    if let Err(e) = verify_expected_preimage(&pre, params) {
201        return (e, CacheEffect::None);
202    }
203    let content = &pre.text;
204
205    if params.old_string.is_empty() {
206        return (
207            "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
208            CacheEffect::None,
209        );
210    }
211
212    if params.old_string == params.new_string {
213        return (
214            "ERROR: old_string and new_string are identical — nothing to change.".into(),
215            CacheEffect::None,
216        );
217    }
218
219    let uses_crlf = pre.uses_crlf;
220    let old_str = &params.old_string;
221    let new_str = &params.new_string;
222
223    let occurrences = content.matches(old_str).count();
224
225    if occurrences > 0 {
226        let args = ReplaceArgs {
227            content,
228            old_str,
229            new_str,
230            occurrences,
231            replace_all: params.replace_all,
232            old_tokens: count_tokens(&params.old_string),
233            new_tokens: count_tokens(&params.new_string),
234        };
235        return do_replace(path, &pre, params, cap, &args);
236    }
237
238    if uses_crlf && !old_str.contains('\r') {
239        let old_crlf = old_str.replace('\n', "\r\n");
240        let occ = content.matches(&old_crlf).count();
241        if occ > 0 {
242            let new_crlf = new_str.replace('\n', "\r\n");
243            let args = ReplaceArgs {
244                content,
245                old_str: &old_crlf,
246                new_str: &new_crlf,
247                occurrences: occ,
248                replace_all: params.replace_all,
249                old_tokens: count_tokens(&params.old_string),
250                new_tokens: count_tokens(&params.new_string),
251            };
252            return do_replace(path, &pre, params, cap, &args);
253        }
254    } else if !uses_crlf && old_str.contains("\r\n") {
255        let old_lf = old_str.replace("\r\n", "\n");
256        let occ = content.matches(&old_lf).count();
257        if occ > 0 {
258            let new_lf = new_str.replace("\r\n", "\n");
259            let args = ReplaceArgs {
260                content,
261                old_str: &old_lf,
262                new_str: &new_lf,
263                occurrences: occ,
264                replace_all: params.replace_all,
265                old_tokens: count_tokens(&params.old_string),
266                new_tokens: count_tokens(&params.new_string),
267            };
268            return do_replace(path, &pre, params, cap, &args);
269        }
270    }
271
272    let normalized_content = trim_trailing_per_line(content);
273    let normalized_old = trim_trailing_per_line(old_str);
274    if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
275        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
276        let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
277        let adapted_old = find_original_span(content, &normalized_old);
278        if let Some(original_match) = adapted_old {
279            let occ = content.matches(&original_match).count();
280            let args = ReplaceArgs {
281                content,
282                old_str: &original_match,
283                new_str: &adapted_new,
284                occurrences: occ,
285                replace_all: params.replace_all,
286                old_tokens: count_tokens(&params.old_string),
287                new_tokens: count_tokens(&params.new_string),
288            };
289            return do_replace(path, &pre, params, cap, &args);
290        }
291    }
292
293    if content.contains(new_str) {
294        return (
295            format!(
296                "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
297                 The edit was likely already applied (by a previous tool call or another agent)."
298            ),
299            CacheEffect::None,
300        );
301    }
302
303    let preview = if old_str.len() > 80 {
304        format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
305    } else {
306        old_str.clone()
307    };
308    let hint = if uses_crlf {
309        " (file uses CRLF line endings)"
310    } else {
311        ""
312    };
313
314    let closest_hint = find_closest_line_hint(content, old_str);
315    let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
316
317    let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
318
319    (
320        format!(
321            "ERROR: old_string not found in {file_path}{hint}. \
322             Make sure it matches exactly (including whitespace/indentation).\n\
323             Searched for: {preview}{closest_hint}{cross_file}{escalation}"
324        ),
325        effect,
326    )
327}
328
329/// Finds the closest matching line in the file content to help the agent
330/// understand what went wrong. Returns a hint string or empty if no useful match.
331fn find_closest_line_hint(content: &str, old_str: &str) -> String {
332    let first_line = old_str.lines().next().unwrap_or("").trim();
333    if first_line.len() < 4 {
334        return String::new();
335    }
336
337    let mut best_line: Option<(usize, &str)> = None;
338
339    for (i, line) in content.lines().enumerate() {
340        if line.contains(first_line) {
341            best_line = Some((i + 1, line));
342            break;
343        }
344    }
345
346    // Try matching with significant identifiers from old_string's first line
347    if best_line.is_none() {
348        let keyword = first_line
349            .split(|c: char| !c.is_alphanumeric() && c != '_')
350            .find(|w| w.len() >= 4);
351
352        if let Some(keyword) = keyword {
353            for (i, line) in content.lines().enumerate() {
354                if line.contains(keyword) {
355                    best_line = Some((i + 1, line));
356                    break;
357                }
358            }
359        }
360    }
361
362    match best_line {
363        Some((line_num, line_content)) => {
364            let trimmed = line_content.trim();
365            let preview = if trimmed.len() > 100 {
366                format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
367            } else {
368                trimmed.to_string()
369            };
370            format!(
371                "\nClosest match at line {line_num}: `{preview}`\n\
372                 Hint: check indentation/whitespace differences."
373            )
374        }
375        None => String::new(),
376    }
377}
378
379/// Auto-escalation: when old_string is not found and the file was previously read
380/// in a compressed mode, re-read in full and return the content so the agent
381/// can immediately retry with the correct old_string. Returns the text to append
382/// plus the [`CacheEffect`] the caller should apply (store full content).
383fn auto_escalate_reread(last_mode: &str, path: &str) -> (String, CacheEffect) {
384    if last_mode.is_empty() || last_mode == "full" {
385        return (String::new(), CacheEffect::None);
386    }
387
388    let Ok(fresh_content) = std::fs::read_to_string(path) else {
389        return (String::new(), CacheEffect::None);
390    };
391
392    let line_count = fresh_content.lines().count();
393    const MAX_LINES: usize = 300;
394
395    let content_preview = if line_count <= MAX_LINES {
396        fresh_content.clone()
397    } else {
398        let lines: Vec<&str> = fresh_content.lines().collect();
399        let head = &lines[..MAX_LINES / 2];
400        let tail = &lines[line_count - MAX_LINES / 2..];
401        let omitted = line_count - MAX_LINES;
402        format!(
403            "{}\n[... {omitted} lines omitted ...]\n{}",
404            head.join("\n"),
405            tail.join("\n")
406        )
407    };
408
409    (
410        format!(
411            "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
412             Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
413        ),
414        CacheEffect::StoreFull(fresh_content),
415    )
416}
417
418fn do_replace(
419    path: &Path,
420    pre: &FilePreimage,
421    params: &EditParams,
422    cap: usize,
423    args: &ReplaceArgs<'_>,
424) -> (String, CacheEffect) {
425    if args.occurrences > 1 && !args.replace_all {
426        return (
427            format!(
428                "ERROR: old_string found {} times in {}. \
429                 Use replace_all=true to replace all, or provide more context to make old_string unique.",
430                args.occurrences,
431                path.display()
432            ),
433            CacheEffect::None,
434        );
435    }
436
437    let new_content = if args.replace_all {
438        args.content.replace(args.old_str, args.new_str)
439    } else {
440        args.content.replacen(args.old_str, args.new_str, 1)
441    };
442
443    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
444    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
445    let health_notice =
446        match crate::core::code_health::gate::evaluate(args.content, &new_content, ext) {
447            crate::core::code_health::gate::GateOutcome::Block(reason) => {
448                return (
449                    format!("ERROR: code-health gate: {reason}"),
450                    CacheEffect::None,
451                );
452            }
453            crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
454        };
455
456    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
457        return (e, CacheEffect::None);
458    }
459
460    let backup_path = if params.backup {
461        let bp = params
462            .backup_path
463            .as_deref()
464            .map(PathBuf::from)
465            .or_else(|| default_backup_path(path));
466        let Some(bp) = bp else {
467            return (
468                format!("ERROR: cannot compute backup path for {}", path.display()),
469                CacheEffect::None,
470            );
471        };
472        if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
473        {
474            return (
475                format!("ERROR: cannot create backup {}: {e}", bp.display()),
476                CacheEffect::None,
477            );
478        }
479        Some(bp.to_string_lossy().to_string())
480    } else {
481        None
482    };
483
484    if let Err(e) =
485        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
486    {
487        return (e, CacheEffect::None);
488    }
489
490    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
491        bt.record_edit(&params.path);
492    }
493
494    let old_lines = args.content.lines().count();
495    let new_lines = new_content.lines().count();
496    let line_delta = new_lines as i64 - old_lines as i64;
497    let delta_str = if line_delta > 0 {
498        format!("+{line_delta}")
499    } else {
500        format!("{line_delta}")
501    };
502
503    let old_tokens = args.old_tokens;
504    let new_tokens = args.new_tokens;
505
506    let replaced_str = if args.replace_all && args.occurrences > 1 {
507        format!("{} replacements", args.occurrences)
508    } else {
509        "1 replacement".into()
510    };
511
512    let short = path.file_name().map_or_else(
513        || path.to_string_lossy().to_string(),
514        |f| f.to_string_lossy().to_string(),
515    );
516
517    let post_mtime_ms = std::fs::metadata(path)
518        .ok()
519        .and_then(|m| m.modified().ok())
520        .map_or(0, system_time_to_millis);
521    let post_fp = FileFingerprint {
522        size: new_content.len() as u64,
523        mtime_ms: post_mtime_ms,
524        md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
525    };
526
527    let mut out = format!(
528        "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
529preimage: bytes={}, mtime_ms={}, md5={}\n\
530postimage: bytes={}, mtime_ms={}, md5={}",
531        pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
532    );
533    if let Some(bp) = backup_path {
534        out.push_str(&format!("\nbackup: {bp}"));
535    }
536    if params.evidence {
537        let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
538        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
539        out.push_str(&diff);
540        out.push_str("\n```");
541    }
542    if let Some(notice) = health_notice {
543        out.push_str("\n\n");
544        out.push_str(&notice);
545    }
546    (out, CacheEffect::Invalidate)
547}
548
549fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
550    let path = Path::new(file_path);
551    let cap = crate::core::limits::max_read_bytes();
552
553    // Deny before the standalone create_dir_all below can materialise a
554    // directory inside a read-only root (#475). The atomic writer guards the
555    // file write too, but this stops an empty-dir side effect first.
556    if let Err(e) = crate::core::pathjail::enforce_writable(path) {
557        return (format!("ERROR: {e}"), CacheEffect::None);
558    }
559
560    let mut preimage: Option<FilePreimage> = None;
561    if path.exists() {
562        let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
563            Ok(p) => p,
564            Err(e) => return (e, CacheEffect::None),
565        };
566        if let Err(e) = verify_expected_preimage(&pre, params) {
567            return (e, CacheEffect::None);
568        }
569        if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
570            return (e, CacheEffect::None);
571        }
572        preimage = Some(pre);
573    }
574
575    if let Some(parent) = path.parent()
576        && !parent.exists()
577        && let Err(e) = std::fs::create_dir_all(parent)
578    {
579        return (
580            format!("ERROR: cannot create directory {}: {e}", parent.display()),
581            CacheEffect::None,
582        );
583    }
584
585    let backup_path = if params.backup {
586        if let Some(pre) = &preimage {
587            let bp = params
588                .backup_path
589                .as_deref()
590                .map(PathBuf::from)
591                .or_else(|| default_backup_path(path));
592            let Some(bp) = bp else {
593                return (
594                    format!("ERROR: cannot compute backup path for {}", path.display()),
595                    CacheEffect::None,
596                );
597            };
598            if let Err(e) =
599                write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
600            {
601                return (
602                    format!("ERROR: cannot create backup {}: {e}", bp.display()),
603                    CacheEffect::None,
604                );
605            }
606            Some(bp.to_string_lossy().to_string())
607        } else {
608            None
609        }
610    } else {
611        None
612    };
613
614    let perms = preimage.as_ref().map(|p| &p.permissions);
615    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
616        return (e, CacheEffect::None);
617    }
618
619    let lines = content.lines().count();
620    let tokens = count_tokens(content);
621    let short = path.file_name().map_or_else(
622        || path.to_string_lossy().to_string(),
623        |f| f.to_string_lossy().to_string(),
624    );
625
626    let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
627    if let Some(bp) = backup_path {
628        out.push_str(&format!("\nbackup: {bp}"));
629    }
630    (out, CacheEffect::Invalidate)
631}
632
633fn trim_trailing_per_line(s: &str) -> String {
634    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
635}
636
637fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
638    let normalized = s.replace("\r\n", "\n");
639    if sep == "\r\n" {
640        normalized.replace('\n', "\r\n")
641    } else {
642        normalized
643    }
644}
645
646/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
647/// after trailing-whitespace trimming per line.
648fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
649    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
650    if needle_lines.is_empty() {
651        return None;
652    }
653
654    let content_lines: Vec<&str> = content.lines().collect();
655
656    'outer: for start in 0..content_lines.len() {
657        if start + needle_lines.len() > content_lines.len() {
658            break;
659        }
660        for (i, nl) in needle_lines.iter().enumerate() {
661            if content_lines[start + i].trim_end() != *nl {
662                continue 'outer;
663            }
664        }
665        let sep = if content.contains("\r\n") {
666            "\r\n"
667        } else {
668            "\n"
669        };
670        return Some(content_lines[start..start + needle_lines.len()].join(sep));
671    }
672    None
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use std::io::Write;
679    use tempfile::NamedTempFile;
680
681    fn make_temp(content: &str) -> NamedTempFile {
682        let mut f = NamedTempFile::new().unwrap();
683        f.write_all(content.as_bytes()).unwrap();
684        f
685    }
686
687    fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
688        EditParams {
689            path: path.to_string_lossy().to_string(),
690            old_string: old.to_string(),
691            new_string: new.to_string(),
692            replace_all,
693            create,
694            expected_md5: None,
695            expected_size: None,
696            expected_mtime_ms: None,
697            backup: false,
698            backup_path: None,
699            evidence: false,
700            diff_max_lines: 200,
701            allow_lossy_utf8: false,
702        }
703    }
704
705    #[test]
706    fn replace_single_occurrence() {
707        let f = make_temp("fn hello() {\n    println!(\"hello\");\n}\n");
708        let mut cache = SessionCache::new();
709        let result = handle(
710            &mut cache,
711            &mk_params(f.path(), "hello", "world", false, false),
712        );
713        assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
714    }
715
716    #[test]
717    fn replace_all() {
718        let f = make_temp("aaa bbb aaa\n");
719        let mut cache = SessionCache::new();
720        let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
721        assert!(result.contains("2 replacements"));
722        let content = std::fs::read_to_string(f.path()).unwrap();
723        assert_eq!(content, "ccc bbb ccc\n");
724    }
725
726    #[test]
727    fn not_found_error() {
728        let f = make_temp("some content\n");
729        let mut cache = SessionCache::new();
730        let result = handle(
731            &mut cache,
732            &mk_params(f.path(), "nonexistent", "x", false, false),
733        );
734        assert!(result.contains("ERROR: old_string not found"));
735    }
736
737    #[test]
738    fn create_new_file() {
739        let dir = tempfile::tempdir().unwrap();
740        let path = dir.path().join("sub/new_file.txt");
741        let mut cache = SessionCache::new();
742        let result = handle(
743            &mut cache,
744            &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
745        );
746        assert!(result.contains("created new_file.txt"));
747        assert!(result.contains("3 lines"));
748        assert!(path.exists());
749    }
750
751    /// #475: creating a file inside a read-only root is refused before the
752    /// directory is even materialised (guard in `handle_create`).
753    #[cfg(not(feature = "no-jail"))]
754    #[test]
755    fn create_denied_in_read_only_root() {
756        let _iso = crate::core::data_dir::isolated_data_dir();
757        let dir = tempfile::tempdir().unwrap();
758        let ro = dir.path().join("refrepo");
759        std::fs::create_dir_all(&ro).unwrap();
760        let path = ro.join("sub/new_file.txt");
761
762        let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
763        crate::test_env::set_var(
764            "LEAN_CTX_READ_ONLY_ROOTS",
765            ro_canon.to_string_lossy().as_ref(),
766        );
767        let mut cache = SessionCache::new();
768        let result = handle(&mut cache, &mk_params(&path, "", "x\n", false, true));
769        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
770
771        assert!(
772            result.contains("read-only"),
773            "create in a read-only root must be refused: {result}"
774        );
775        assert!(!path.exists(), "no file may be created in a read-only root");
776        assert!(
777            !ro.join("sub").exists(),
778            "no directory may be created in a read-only root"
779        );
780    }
781
782    /// #475: editing an existing file inside a read-only root is refused at the
783    /// atomic-write choke point (`write_atomic_bytes_with_permissions`), leaving
784    /// the original bytes intact.
785    #[cfg(not(feature = "no-jail"))]
786    #[test]
787    fn edit_denied_in_read_only_root() {
788        let _iso = crate::core::data_dir::isolated_data_dir();
789        let dir = tempfile::tempdir().unwrap();
790        let ro = dir.path().join("refrepo");
791        std::fs::create_dir_all(&ro).unwrap();
792        let path = ro.join("a.txt");
793        std::fs::write(&path, "alpha beta\n").unwrap();
794
795        let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
796        crate::test_env::set_var(
797            "LEAN_CTX_READ_ONLY_ROOTS",
798            ro_canon.to_string_lossy().as_ref(),
799        );
800        let mut cache = SessionCache::new();
801        let result = handle(
802            &mut cache,
803            &mk_params(&path, "alpha", "OMEGA", false, false),
804        );
805        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
806
807        assert!(
808            result.contains("read-only"),
809            "edit in a read-only root must be refused: {result}"
810        );
811        assert_eq!(
812            std::fs::read_to_string(&path).unwrap(),
813            "alpha beta\n",
814            "the file must be left untouched"
815        );
816    }
817
818    /// #475 (the exact #464 regression): a caller-supplied `backup_path` must
819    /// not be a side door into a read-only root. Even when the *target* file is
820    /// writable, redirecting the pre-edit backup into a read-only root is denied
821    /// — and because the backup is written first, the denial is fail-closed: the
822    /// target keeps its original bytes and no backup is dropped in the root.
823    #[cfg(not(feature = "no-jail"))]
824    #[test]
825    fn backup_path_cannot_smuggle_writes_into_read_only_root() {
826        let _iso = crate::core::data_dir::isolated_data_dir();
827        let dir = tempfile::tempdir().unwrap();
828        let ro = dir.path().join("refrepo");
829        let work = dir.path().join("work");
830        std::fs::create_dir_all(&ro).unwrap();
831        std::fs::create_dir_all(&work).unwrap();
832        let target = work.join("a.txt"); // writable target, outside the RO root
833        std::fs::write(&target, "alpha beta\n").unwrap();
834        let smuggled = ro.join("leak.bak"); // attacker-chosen backup inside RO root
835
836        let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
837        crate::test_env::set_var(
838            "LEAN_CTX_READ_ONLY_ROOTS",
839            ro_canon.to_string_lossy().as_ref(),
840        );
841        let mut params = mk_params(&target, "alpha", "OMEGA", false, false);
842        params.backup = true;
843        params.backup_path = Some(smuggled.to_string_lossy().to_string());
844        let mut cache = SessionCache::new();
845        let result = handle(&mut cache, &params);
846        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
847
848        assert!(
849            result.contains("read-only"),
850            "a backup_path into a read-only root must be refused: {result}"
851        );
852        assert!(
853            !smuggled.exists(),
854            "no backup may be smuggled into a read-only root"
855        );
856        assert_eq!(
857            std::fs::read_to_string(&target).unwrap(),
858            "alpha beta\n",
859            "fail-closed: the writable target must be untouched when the backup is denied"
860        );
861    }
862
863    /// #475 end-to-end via the *real* config mechanism a user would use:
864    /// `read_only_roots` declared in `config.toml` (not the env var) must make
865    /// `ctx_edit` refuse the write. Exercises the `Config::load()` → predicate →
866    /// tool-denial chain.
867    #[cfg(not(feature = "no-jail"))]
868    #[test]
869    fn edit_denied_via_config_read_only_roots() {
870        let _iso = crate::core::data_dir::isolated_data_dir();
871        let dir = tempfile::tempdir().unwrap();
872        let ro = dir.path().join("refrepo");
873        std::fs::create_dir_all(&ro).unwrap();
874        let path = ro.join("a.txt");
875        std::fs::write(&path, "alpha beta\n").unwrap();
876
877        // Write the user-facing config.toml into the isolated config dir.
878        let cfg_path = crate::core::config::Config::path().unwrap();
879        if let Some(parent) = cfg_path.parent() {
880            std::fs::create_dir_all(parent).unwrap();
881        }
882        // TOML literal string ('...') — no escaping of the temp path needed.
883        std::fs::write(
884            &cfg_path,
885            format!("read_only_roots = ['{}']\n", ro.to_string_lossy()),
886        )
887        .unwrap();
888
889        let mut cache = SessionCache::new();
890        let result = handle(
891            &mut cache,
892            &mk_params(&path, "alpha", "OMEGA", false, false),
893        );
894
895        assert!(
896            result.contains("read-only"),
897            "config-declared read_only_roots must deny the edit: {result}"
898        );
899        assert_eq!(
900            std::fs::read_to_string(&path).unwrap(),
901            "alpha beta\n",
902            "the file must be left untouched"
903        );
904    }
905
906    // GH #459: parent dir read-only, file inode writable (the bind-mount
907    // sandbox shape). The atomic tempfile + rename needs *directory* write
908    // permission and fails; the in-place fallback overwrites the existing inode
909    // and succeeds. Skipped under root, which bypasses the directory permission
910    // check (the atomic path would then succeed and the fallback never runs —
911    // the write still lands correctly either way).
912    #[cfg(unix)]
913    #[test]
914    fn write_falls_back_on_readonly_parent_dir() {
915        use std::os::unix::fs::PermissionsExt;
916
917        // SAFETY: geteuid() takes no arguments and only reads the caller's uid.
918        if unsafe { libc::geteuid() } == 0 {
919            return;
920        }
921
922        let dir = tempfile::tempdir().unwrap();
923        let path = dir.path().join("opencode.jsonc");
924        std::fs::write(&path, b"hello").unwrap();
925
926        // r-x parent: create_new tempfile + rename fail with EACCES, but the
927        // existing file mode (0o644) still allows O_WRONLY|O_TRUNC.
928        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
929
930        let res = write_atomic_bytes_with_permissions(&path, b"world", None);
931
932        // Restore so tempdir cleanup can remove the directory.
933        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
934
935        assert!(res.is_ok(), "in-place fallback should succeed: {res:?}");
936        assert_eq!(std::fs::read(&path).unwrap(), b"world");
937    }
938
939    // GH #459 end-to-end: the full ctx_edit flow (read -> preimage -> write)
940    // must succeed when the parent dir is read-only but the file is writable.
941    #[cfg(unix)]
942    #[test]
943    fn handle_edit_succeeds_on_readonly_parent_dir() {
944        use std::os::unix::fs::PermissionsExt;
945
946        // SAFETY: geteuid() takes no arguments and only reads the caller's uid.
947        if unsafe { libc::geteuid() } == 0 {
948            return;
949        }
950
951        let dir = tempfile::tempdir().unwrap();
952        let path = dir.path().join("opencode.jsonc");
953        std::fs::write(&path, "hello world\n").unwrap();
954        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
955
956        let mut cache = SessionCache::new();
957        let result = handle(
958            &mut cache,
959            &mk_params(&path, "hello", "goodbye", false, false),
960        );
961
962        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
963
964        assert!(
965            result.contains('✓'),
966            "edit should succeed via in-place fallback: {result}"
967        );
968        assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye world\n");
969    }
970
971    #[test]
972    fn unique_match_succeeds() {
973        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
974        let mut cache = SessionCache::new();
975        let result = handle(
976            &mut cache,
977            &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
978        );
979        assert!(result.contains("✓"));
980        assert!(result.contains("1 replacement"));
981        let content = std::fs::read_to_string(f.path()).unwrap();
982        assert!(content.contains("let x = 99"));
983    }
984
985    #[test]
986    fn crlf_file_with_lf_search() {
987        let f = make_temp("line1\r\nline2\r\nline3\r\n");
988        let mut cache = SessionCache::new();
989        let result = handle(
990            &mut cache,
991            &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
992        );
993        assert!(result.contains("✓"), "CRLF fallback should work: {result}");
994        let content = std::fs::read_to_string(f.path()).unwrap();
995        assert!(
996            content.contains("changed1\r\nchanged2"),
997            "new_string should be adapted to CRLF: {content:?}"
998        );
999        assert!(
1000            content.contains("\r\nline3\r\n"),
1001            "rest of file should keep CRLF: {content:?}"
1002        );
1003    }
1004
1005    #[test]
1006    fn lf_file_with_crlf_search() {
1007        let f = make_temp("line1\nline2\nline3\n");
1008        let mut cache = SessionCache::new();
1009        let result = handle(
1010            &mut cache,
1011            &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
1012        );
1013        assert!(result.contains("✓"), "LF fallback should work: {result}");
1014        let content = std::fs::read_to_string(f.path()).unwrap();
1015        assert!(
1016            content.contains("a\nb"),
1017            "new_string should be adapted to LF: {content:?}"
1018        );
1019    }
1020
1021    #[test]
1022    fn trailing_whitespace_tolerance() {
1023        let f = make_temp("  let x = 1;  \n  let y = 2;\n");
1024        let mut cache = SessionCache::new();
1025        let result = handle(
1026            &mut cache,
1027            &mk_params(
1028                f.path(),
1029                "  let x = 1;\n  let y = 2;",
1030                "  let x = 10;\n  let y = 20;",
1031                false,
1032                false,
1033            ),
1034        );
1035        assert!(
1036            result.contains("✓"),
1037            "trailing whitespace tolerance should work: {result}"
1038        );
1039        let content = std::fs::read_to_string(f.path()).unwrap();
1040        assert!(content.contains("let x = 10;"));
1041        assert!(content.contains("let y = 20;"));
1042    }
1043
1044    #[test]
1045    fn crlf_with_trailing_whitespace() {
1046        let f = make_temp("  const a = 1;  \r\n  const b = 2;\r\n");
1047        let mut cache = SessionCache::new();
1048        let result = handle(
1049            &mut cache,
1050            &mk_params(
1051                f.path(),
1052                "  const a = 1;\n  const b = 2;",
1053                "  const a = 10;\n  const b = 20;",
1054                false,
1055                false,
1056            ),
1057        );
1058        assert!(
1059            result.contains("✓"),
1060            "CRLF + trailing whitespace should work: {result}"
1061        );
1062        let content = std::fs::read_to_string(f.path()).unwrap();
1063        assert!(content.contains("const a = 10;"));
1064        assert!(content.contains("const b = 20;"));
1065    }
1066
1067    #[test]
1068    fn rejects_invalid_utf8_by_default() {
1069        let mut f = NamedTempFile::new().unwrap();
1070        f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1071        let mut cache = SessionCache::new();
1072        let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1073        assert!(
1074            result.contains("not valid UTF-8"),
1075            "expected utf8 rejection, got: {result}"
1076        );
1077    }
1078
1079    #[test]
1080    fn allows_lossy_utf8_only_when_enabled() {
1081        let mut f = NamedTempFile::new().unwrap();
1082        f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1083        let mut cache = SessionCache::new();
1084        let mut p = mk_params(f.path(), "a", "b", false, false);
1085        p.allow_lossy_utf8 = true;
1086        let result = handle(&mut cache, &p);
1087        assert!(
1088            !result.contains("not valid UTF-8"),
1089            "lossy mode should avoid utf8 hard error, got: {result}"
1090        );
1091    }
1092
1093    #[test]
1094    fn expected_md5_mismatch_fails_without_writing() {
1095        let f = make_temp("aaa\n");
1096        let mut cache = SessionCache::new();
1097        let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1098        p.expected_md5 = Some("deadbeef".to_string());
1099        let result = handle(&mut cache, &p);
1100        assert!(
1101            result.contains("preimage mismatch"),
1102            "expected preimage mismatch, got: {result}"
1103        );
1104        let content = std::fs::read_to_string(f.path()).unwrap();
1105        assert_eq!(content, "aaa\n");
1106    }
1107
1108    #[test]
1109    fn backup_is_created_when_enabled() {
1110        let f = make_temp("aaa\n");
1111        let mut cache = SessionCache::new();
1112        let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1113        p.backup = true;
1114        let out = handle(&mut cache, &p);
1115        assert!(out.contains("backup:"), "expected backup path, got: {out}");
1116        let bp = out
1117            .lines()
1118            .find_map(|l| l.strip_prefix("backup: "))
1119            .expect("backup line");
1120        let backup_content = std::fs::read_to_string(bp).unwrap();
1121        assert_eq!(backup_content, "aaa\n");
1122        let content = std::fs::read_to_string(f.path()).unwrap();
1123        assert_eq!(content, "bbb\n");
1124    }
1125
1126    #[test]
1127    fn evidence_diff_is_emitted_when_enabled() {
1128        let f = make_temp("line1\nline2\n");
1129        let mut cache = SessionCache::new();
1130        let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1131        p.evidence = true;
1132        p.diff_max_lines = 50;
1133        let out = handle(&mut cache, &p);
1134        assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1135        assert!(
1136            out.contains("preimage:"),
1137            "expected preimage metadata, got: {out}"
1138        );
1139        assert!(
1140            out.contains("postimage:"),
1141            "expected postimage metadata, got: {out}"
1142        );
1143    }
1144
1145    /// Issue #320: run_io performs the full edit without any cache handle, so the
1146    /// MCP layer can avoid holding the global cache write-lock across disk I/O.
1147    /// A successful edit reports an Invalidate effect.
1148    #[test]
1149    fn run_io_success_reports_invalidate_effect() {
1150        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
1151        let (text, effect) = run_io(
1152            &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1153            "",
1154        );
1155        assert!(text.contains("✓"), "expected success: {text}");
1156        assert!(
1157            matches!(effect, CacheEffect::Invalidate),
1158            "successful edit must invalidate the cache entry"
1159        );
1160        let content = std::fs::read_to_string(f.path()).unwrap();
1161        assert!(content.contains("let x = 99"));
1162    }
1163
1164    #[test]
1165    fn run_io_failure_reports_no_cache_effect() {
1166        let f = make_temp("some content\n");
1167        let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1168        assert!(text.contains("ERROR: old_string not found"));
1169        assert!(
1170            matches!(effect, CacheEffect::None),
1171            "a failed edit must not mutate the cache"
1172        );
1173    }
1174
1175    /// Issue #320: concurrent edits to *different* files must all succeed without
1176    /// serializing on any shared lock — run_io takes no cache, so there is nothing
1177    /// global to contend on.
1178    #[test]
1179    fn run_io_concurrent_edits_to_different_files_all_succeed() {
1180        use std::sync::Arc;
1181        let dir = Arc::new(tempfile::tempdir().unwrap());
1182        let n = 16;
1183        let mut paths = Vec::new();
1184        for i in 0..n {
1185            let p = dir.path().join(format!("file_{i}.txt"));
1186            std::fs::write(&p, format!("value = {i}\n")).unwrap();
1187            paths.push(p);
1188        }
1189        let barrier = Arc::new(std::sync::Barrier::new(n));
1190        let mut handles = Vec::new();
1191        for (i, p) in paths.into_iter().enumerate() {
1192            let barrier = Arc::clone(&barrier);
1193            handles.push(std::thread::spawn(move || {
1194                barrier.wait();
1195                let (text, effect) = run_io(
1196                    &mk_params(
1197                        &p,
1198                        &format!("value = {i}"),
1199                        &format!("value = {}", i + 1000),
1200                        false,
1201                        false,
1202                    ),
1203                    "",
1204                );
1205                assert!(text.contains("✓"), "edit {i} failed: {text}");
1206                assert!(matches!(effect, CacheEffect::Invalidate));
1207                (p, i)
1208            }));
1209        }
1210        for h in handles {
1211            let (p, i) = h.join().unwrap();
1212            let content = std::fs::read_to_string(&p).unwrap();
1213            assert_eq!(content, format!("value = {}\n", i + 1000));
1214        }
1215    }
1216
1217    #[test]
1218    fn run_io_escalation_reports_store_full_effect() {
1219        // A file previously read in a compressed mode ("signatures") triggers
1220        // auto-escalation when old_string is not found: the full content is
1221        // returned for re-store.
1222        let f = make_temp("line a\nline b\nline c\n");
1223        let (text, effect) = run_io(
1224            &mk_params(f.path(), "definitely-not-present", "x", false, false),
1225            "signatures",
1226        );
1227        assert!(
1228            text.contains("[auto-escalation]"),
1229            "expected escalation: {text}"
1230        );
1231        match effect {
1232            CacheEffect::StoreFull(content) => {
1233                assert!(content.contains("line a") && content.contains("line c"));
1234            }
1235            _ => panic!("escalation must report a StoreFull cache effect"),
1236        }
1237    }
1238
1239    #[test]
1240    fn apply_cache_effect_invalidate_and_store() {
1241        let f = make_temp("hello\n");
1242        let mut cache = SessionCache::new();
1243        cache.store(&f.path().to_string_lossy(), "hello\n");
1244        apply_cache_effect(
1245            &mut cache,
1246            &f.path().to_string_lossy(),
1247            CacheEffect::Invalidate,
1248        );
1249        assert!(
1250            cache.get(&f.path().to_string_lossy()).is_none(),
1251            "Invalidate must drop the entry"
1252        );
1253        apply_cache_effect(
1254            &mut cache,
1255            &f.path().to_string_lossy(),
1256            CacheEffect::StoreFull("fresh\n".to_string()),
1257        );
1258        assert!(
1259            cache.get(&f.path().to_string_lossy()).is_some(),
1260            "StoreFull must re-populate the entry"
1261        );
1262    }
1263
1264    #[test]
1265    fn identical_old_new_rejected() {
1266        let f = make_temp("fn main() {}\n");
1267        let mut cache = SessionCache::new();
1268        let result = handle(
1269            &mut cache,
1270            &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1271        );
1272        assert!(result.contains("identical"));
1273    }
1274
1275    #[test]
1276    fn edit_already_applied_detected() {
1277        let f = make_temp("fn updated() {}\n");
1278        let (text, effect) = run_io(
1279            &mk_params(
1280                f.path(),
1281                "fn original() {}",
1282                "fn updated() {}",
1283                false,
1284                false,
1285            ),
1286            "",
1287        );
1288        assert!(text.contains("already exists"));
1289        assert!(text.contains("already applied"));
1290        assert!(matches!(effect, CacheEffect::None));
1291    }
1292
1293    #[test]
1294    fn closest_line_hint_shown() {
1295        let f = make_temp("  fn hello() {\n    println!(\"hi\");\n  }\n");
1296        let (text, _) = run_io(
1297            &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1298            "",
1299        );
1300        assert!(text.contains("Closest match at line"));
1301    }
1302
1303    #[test]
1304    fn missing_file_suggests_relocated_path() {
1305        let dir = tempfile::tempdir().unwrap();
1306        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1307        std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1308        std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1309
1310        let (text, effect) = run_io(
1311            &mk_params(
1312                &dir.path().join("src/old/gizmo.rs"),
1313                "fn gizmo() {}",
1314                "fn gizmo2() {}",
1315                false,
1316                false,
1317            ),
1318            "",
1319        );
1320        assert!(text.contains("same-named file was found"), "got: {text}");
1321        assert!(text.contains("gizmo.rs"), "got: {text}");
1322        assert!(matches!(effect, CacheEffect::None));
1323    }
1324
1325    #[test]
1326    fn old_string_in_other_file_is_reported() {
1327        let dir = tempfile::tempdir().unwrap();
1328        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1329        let target = dir.path().join("a.rs");
1330        std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1331        std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1332
1333        let (text, _) = run_io(
1334            &mk_params(
1335                &target,
1336                "fn the_target_symbol() {}",
1337                "fn renamed() {}",
1338                false,
1339                false,
1340            ),
1341            "",
1342        );
1343        assert!(text.contains("matching line exists in"), "got: {text}");
1344        assert!(text.contains("b.rs"), "got: {text}");
1345    }
1346
1347    // P0-6 (#418): a symlink at the edit path must be rejected on the read side —
1348    // a link planted inside the jail could otherwise read/overwrite outside it.
1349    #[cfg(unix)]
1350    #[test]
1351    fn editing_through_a_symlink_is_rejected() {
1352        let dir = tempfile::tempdir().unwrap();
1353        let real = dir.path().join("real.rs");
1354        std::fs::write(&real, "fn old() {}\n").unwrap();
1355        let link = dir.path().join("link.rs");
1356        std::os::unix::fs::symlink(&real, &link).unwrap();
1357
1358        let (text, effect) = run_io(
1359            &mk_params(&link, "fn old() {}", "fn new() {}", false, false),
1360            "",
1361        );
1362        assert!(text.contains("symlink"), "got: {text}");
1363        assert!(matches!(effect, CacheEffect::None));
1364        // Target untouched.
1365        assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
1366    }
1367
1368    // P0-6 (#418): the write side must also reject a symlink destination
1369    // (defense in depth for create-mode and backup paths).
1370    #[cfg(unix)]
1371    #[test]
1372    fn creating_over_a_symlink_is_rejected() {
1373        let dir = tempfile::tempdir().unwrap();
1374        let real = dir.path().join("victim.txt");
1375        std::fs::write(&real, "precious").unwrap();
1376        let link = dir.path().join("innocent.txt");
1377        std::os::unix::fs::symlink(&real, &link).unwrap();
1378
1379        let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
1380        assert!(
1381            text.contains("symlink") || text.contains("ERROR"),
1382            "got: {text}"
1383        );
1384        assert_eq!(
1385            std::fs::read_to_string(&real).unwrap(),
1386            "precious",
1387            "symlink target must not be modified"
1388        );
1389    }
1390
1391    #[test]
1392    fn regular_file_edit_still_works_after_symlink_guard() {
1393        let dir = tempfile::tempdir().unwrap();
1394        let file = dir.path().join("normal.rs");
1395        std::fs::write(&file, "fn old() {}\n").unwrap();
1396
1397        let (text, _) = run_io(
1398            &mk_params(&file, "fn old() {}", "fn new() {}", false, false),
1399            "",
1400        );
1401        assert!(
1402            text.contains("Edit applied") || !text.starts_with("ERROR"),
1403            "got: {text}"
1404        );
1405        assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
1406    }
1407}