Skip to main content

lean_ctx/tools/
ctx_edit.rs

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