Skip to main content

harn_hostlib/
fs.rs

1//! Session-scoped staged filesystem mode.
2//!
3//! `hostlib_fs_set_mode({session_id, mode: "staged"})` makes hostlib file
4//! mutations land in a durable per-session overlay under
5//! `.harn/state/staged/<session_id>/`. Reads made by the same session consult
6//! that overlay first, so agent loops see their own pending writes without
7//! touching the working tree until `hostlib_fs_commit_staged`.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs::{self as stdfs};
11use std::io::Write;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use std::sync::{Mutex, OnceLock};
15
16use harn_vm::agent_events::AgentEvent;
17use harn_vm::process_sandbox::{check_fs_path_scope, FsAccess};
18use harn_vm::VmValue;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use crate::error::HostlibError;
23use crate::registry::{BuiltinRegistry, HostlibCapability};
24use crate::tools::args::{
25    build_dict, dict_arg, optional_bool, optional_int, optional_string, optional_string_list,
26    require_string, resolve_host_path, str_value, to_agent_path,
27};
28use crate::tools::permissions::enforce_path_scope;
29
30const SET_MODE_BUILTIN: &str = "hostlib_fs_set_mode";
31const STATUS_BUILTIN: &str = "hostlib_fs_staged_status";
32const COMMIT_BUILTIN: &str = "hostlib_fs_commit_staged";
33const DISCARD_BUILTIN: &str = "hostlib_fs_discard_staged";
34const SAFE_TEXT_PATCH_BUILTIN: &str = "hostlib_fs_safe_text_patch";
35const READ_TEXT_BUILTIN: &str = "hostlib_fs_read_text";
36const EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN: &str = "hostlib_fs_emit_safe_text_patch_result";
37
38const MANIFEST_VERSION: u32 = 1;
39const STATE_REL: &[&str] = &[".harn", "state", "staged"];
40
41mod safe_text_patch_lock;
42
43#[cfg(test)]
44use safe_text_patch_lock::open_safe_text_patch_lock;
45use safe_text_patch_lock::{acquire_safe_text_patch_lock, safe_text_patch_lock_root};
46pub use safe_text_patch_lock::{scope_safe_text_patch_lock_root, ScopedSafeTextPatchLockRoot};
47
48/// Hostlib filesystem capability handle.
49#[derive(Default)]
50pub struct FsCapability;
51
52impl HostlibCapability for FsCapability {
53    fn module_name(&self) -> &'static str {
54        "fs"
55    }
56
57    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
58        registry.register_fn("fs", SET_MODE_BUILTIN, "set_mode", set_mode_builtin);
59        registry.register_fn("fs", STATUS_BUILTIN, "staged_status", staged_status_builtin);
60        registry.register_fn("fs", COMMIT_BUILTIN, "commit_staged", commit_staged_builtin);
61        registry.register_fn(
62            "fs",
63            DISCARD_BUILTIN,
64            "discard_staged",
65            discard_staged_builtin,
66        );
67        // `safe_text_patch` and `read_text` touch arbitrary host paths, so
68        // they share the deterministic-tools gate with `tools::*` file I/O.
69        registry.register_gated_fn(
70            "fs",
71            SAFE_TEXT_PATCH_BUILTIN,
72            "safe_text_patch",
73            safe_text_patch_builtin,
74        );
75        registry.register_gated_fn("fs", READ_TEXT_BUILTIN, "read_text", read_text_builtin);
76        registry.register_fn(
77            "fs",
78            EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
79            "emit_safe_text_patch_result",
80            emit_safe_text_patch_result_builtin,
81        );
82    }
83}
84
85/// Filesystem mode for one hostlib session.
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum FsMode {
89    /// Mutations apply to the working tree immediately.
90    Immediate,
91    /// Mutations are recorded in the staging layer until committed.
92    Staged,
93}
94
95impl FsMode {
96    fn parse(builtin: &'static str, raw: &str) -> Result<Self, HostlibError> {
97        match raw {
98            "immediate" => Ok(Self::Immediate),
99            "staged" => Ok(Self::Staged),
100            other => Err(HostlibError::InvalidParameter {
101                builtin,
102                param: "mode",
103                message: format!("expected \"immediate\" or \"staged\", got `{other}`"),
104            }),
105        }
106    }
107
108    /// Wire string used by hostlib schemas.
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::Immediate => "immediate",
112            Self::Staged => "staged",
113        }
114    }
115}
116
117#[derive(Clone, Debug, Serialize, Deserialize)]
118struct Manifest {
119    version: u32,
120    session_id: String,
121    mode: FsMode,
122    root: String,
123    entries: BTreeMap<String, StagedEntry>,
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127#[serde(tag = "kind", rename_all = "snake_case")]
128enum StagedEntry {
129    Write {
130        body_hash: String,
131        len: u64,
132        created_at_ms: i64,
133    },
134    Delete {
135        recursive: bool,
136        created_at_ms: i64,
137    },
138}
139
140impl StagedEntry {
141    fn created_at_ms(&self) -> i64 {
142        match self {
143            Self::Write { created_at_ms, .. } | Self::Delete { created_at_ms, .. } => {
144                *created_at_ms
145            }
146        }
147    }
148
149    fn body_len(&self) -> u64 {
150        match self {
151            Self::Write { len, .. } => *len,
152            Self::Delete { .. } => 0,
153        }
154    }
155}
156
157#[derive(Clone, Debug)]
158struct SessionState {
159    session_id: String,
160    mode: FsMode,
161    root: PathBuf,
162    entries: BTreeMap<PathBuf, StagedEntry>,
163}
164
165#[derive(Clone, Debug)]
166pub(crate) struct WriteOutcome {
167    pub(crate) created: bool,
168    pub(crate) bytes_written: usize,
169}
170
171#[derive(Clone, Debug)]
172pub(crate) struct OverlayDirEntry {
173    pub(crate) name: String,
174    pub(crate) is_dir: bool,
175    pub(crate) is_symlink: bool,
176    pub(crate) size: u64,
177}
178
179/// Summary of staged filesystem changes for one session.
180#[derive(Clone, Debug)]
181pub struct StagedStatus {
182    /// Pending path changes, sorted by path.
183    pub pending_writes: Vec<PendingWrite>,
184    /// Bytes stored in staged write bodies.
185    pub total_bytes_pending: u64,
186    /// Age in milliseconds of the oldest pending change, or 0 when empty.
187    pub oldest_pending_age_ms: i64,
188}
189
190#[derive(Clone, Debug)]
191/// One pending staged filesystem change.
192pub struct PendingWrite {
193    /// Absolute path affected by this staged change.
194    pub path: String,
195    /// Change kind (`write`, `delete`, or reserved future `move`).
196    pub kind: &'static str,
197    /// Bytes the final staged view adds at this path.
198    pub bytes_added: u64,
199    /// Bytes the final staged view removes at this path.
200    pub bytes_removed: u64,
201}
202
203/// Result returned after changing a session's filesystem mode.
204#[derive(Clone, Debug)]
205pub struct SetModeResult {
206    /// Mode active before the change.
207    pub previous_mode: FsMode,
208}
209
210/// Result returned after applying staged changes to disk.
211#[derive(Clone, Debug)]
212pub struct CommitResult {
213    /// Paths successfully applied to disk.
214    pub committed_paths: Vec<String>,
215    /// Paths that failed to apply, with human-readable reasons.
216    pub failed_paths_with_reasons: Vec<(String, String)>,
217}
218
219/// Result returned after dropping staged changes.
220#[derive(Clone, Debug)]
221pub struct DiscardResult {
222    /// Paths whose staged entries were removed.
223    pub discarded_paths: Vec<String>,
224}
225
226static SESSIONS: OnceLock<Mutex<BTreeMap<String, SessionState>>> = OnceLock::new();
227
228fn sessions() -> &'static Mutex<BTreeMap<String, SessionState>> {
229    SESSIONS.get_or_init(|| Mutex::new(BTreeMap::new()))
230}
231
232/// Lock the session map, panicking with one canonical message if a prior
233/// holder poisoned the mutex. Every accessor goes through here so the poison
234/// policy and message live in exactly one place.
235fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
236    sessions()
237        .lock()
238        .expect("hostlib fs session mutex poisoned")
239}
240
241/// Remember the workspace root associated with a live session.
242///
243/// ACP calls this when a prompt starts so Harn code can call
244/// `hostlib_fs_set_mode({session_id, mode})` without also passing a root.
245pub fn configure_session_root(session_id: &str, root: &Path) {
246    if session_id.trim().is_empty() {
247        return;
248    }
249    let root = normalize_logical(root);
250    let mut guard = lock_sessions();
251    match guard.get_mut(session_id) {
252        Some(state) if state.entries.is_empty() => {
253            state.root = root;
254        }
255        Some(_) => {}
256        None => {
257            let state = load_state(session_id, Some(root.clone())).unwrap_or(SessionState {
258                session_id: session_id.to_string(),
259                mode: FsMode::Immediate,
260                root,
261                entries: BTreeMap::new(),
262            });
263            guard.insert(session_id.to_string(), state);
264        }
265    }
266}
267
268/// Return the root currently associated with a hostlib session.
269pub fn configured_session_root(session_id: &str) -> Option<PathBuf> {
270    if session_id.trim().is_empty() {
271        return None;
272    }
273    let guard = lock_sessions();
274    guard.get(session_id).map(|state| state.root.clone())
275}
276
277/// Set a session's filesystem mode.
278pub fn set_mode(
279    session_id: &str,
280    mode: FsMode,
281    root: Option<&Path>,
282) -> Result<SetModeResult, HostlibError> {
283    validate_session_id(SET_MODE_BUILTIN, session_id)?;
284    let mut guard = lock_sessions();
285    let mut state = state_for_locked(&mut guard, session_id, root.map(normalize_logical))?;
286    let previous_mode = state.mode;
287    state.mode = mode;
288    persist_state(&state, "set_mode", None).map_err(|err| HostlibError::Backend {
289        builtin: SET_MODE_BUILTIN,
290        message: err,
291    })?;
292    guard.insert(session_id.to_string(), state);
293    Ok(SetModeResult { previous_mode })
294}
295
296/// Return the staged status for a session.
297pub fn staged_status(session_id: &str) -> Result<StagedStatus, HostlibError> {
298    validate_session_id(STATUS_BUILTIN, session_id)?;
299    let mut guard = lock_sessions();
300    let state = state_for_locked(&mut guard, session_id, None)?;
301    let status = status_from_state(&state);
302    guard.insert(session_id.to_string(), state);
303    Ok(status)
304}
305
306/// Return native filesystem paths for every pending staged entry.
307///
308/// Public staged status normalizes paths for the agent/tool surface. Internal
309/// callers that need to read from the filesystem must keep the native
310/// [`PathBuf`]s, especially on Windows where slash-normalized display strings
311/// are not always valid filesystem paths.
312pub(crate) fn staged_pending_paths(session_id: &str) -> Result<BTreeSet<PathBuf>, HostlibError> {
313    validate_session_id(STATUS_BUILTIN, session_id)?;
314    let mut guard = lock_sessions();
315    let state = state_for_locked(&mut guard, session_id, None)?;
316    let paths = state.entries.keys().cloned().collect();
317    guard.insert(session_id.to_string(), state);
318    Ok(paths)
319}
320
321/// Commit staged changes for all paths or for a filtered path list.
322pub fn commit_staged(session_id: &str, paths: &[String]) -> Result<CommitResult, HostlibError> {
323    validate_session_id(COMMIT_BUILTIN, session_id)?;
324    let mut guard = lock_sessions();
325    let mut state = state_for_locked(&mut guard, session_id, None)?;
326    let selected = selected_paths(&state, paths);
327    let mut committed_paths = Vec::new();
328    let mut failed_paths_with_reasons = Vec::new();
329
330    for path in selected {
331        let Some(entry) = state.entries.get(&path).cloned() else {
332            continue;
333        };
334        let path_label = to_agent_path(&path);
335        // The overlay always lives inside the workspace, but commit flushes
336        // to the *target* working-tree path. Enforce workspace-root scope
337        // against that target so a staged entry — possibly persisted under
338        // a looser policy in an earlier session — can never write outside
339        // the roots active at commit time.
340        let access = match entry {
341            StagedEntry::Write { .. } => FsAccess::Write,
342            StagedEntry::Delete { .. } => FsAccess::Delete,
343        };
344        if let Err(violation) = check_fs_path_scope(&path, access) {
345            failed_paths_with_reasons.push((path_label, violation.message(COMMIT_BUILTIN)));
346            continue;
347        }
348        match commit_entry(&state, &path, &entry) {
349            Ok(()) => {
350                state.entries.remove(&path);
351                committed_paths.push(path_label);
352            }
353            Err(reason) => failed_paths_with_reasons.push((path_label, reason)),
354        }
355    }
356
357    persist_state(&state, "commit_staged", None).map_err(|err| HostlibError::Backend {
358        builtin: COMMIT_BUILTIN,
359        message: err,
360    })?;
361    emit_staged_update(&state);
362    guard.insert(session_id.to_string(), state);
363    Ok(CommitResult {
364        committed_paths,
365        failed_paths_with_reasons,
366    })
367}
368
369/// Discard staged changes for all paths or for a filtered path list.
370pub fn discard_staged(session_id: &str, paths: &[String]) -> Result<DiscardResult, HostlibError> {
371    validate_session_id(DISCARD_BUILTIN, session_id)?;
372    let mut guard = lock_sessions();
373    let mut state = state_for_locked(&mut guard, session_id, None)?;
374    let selected = selected_paths(&state, paths);
375    let mut discarded_paths = Vec::new();
376    for path in selected {
377        if state.entries.remove(&path).is_some() {
378            discarded_paths.push(to_agent_path(&path));
379        }
380    }
381    persist_state(&state, "discard_staged", None).map_err(|err| HostlibError::Backend {
382        builtin: DISCARD_BUILTIN,
383        message: err,
384    })?;
385    emit_staged_update(&state);
386    guard.insert(session_id.to_string(), state);
387    Ok(DiscardResult { discarded_paths })
388}
389
390/// Remove all persisted staged-fs state for a caller-owned throw-away session.
391///
392/// Normal agent sessions keep their manifest after `discard_staged` so hosts can
393/// continue reporting session state. Transient dry-run sessions own their ids,
394/// though, and should remove both the in-memory entry and on-disk overlay after
395/// their preview is rendered.
396pub fn remove_session_state(session_id: &str, root: Option<&Path>) -> Result<(), HostlibError> {
397    validate_session_id(DISCARD_BUILTIN, session_id)?;
398    let mut guard = lock_sessions();
399    let state = match guard.remove(session_id) {
400        Some(state) => state,
401        None => load_state(session_id, root.map(normalize_logical)).map_err(|err| {
402            HostlibError::Backend {
403                builtin: DISCARD_BUILTIN,
404                message: err,
405            }
406        })?,
407    };
408    let dir = session_dir(&state.root, &state.session_id);
409    match stdfs::remove_dir_all(&dir) {
410        Ok(()) => Ok(()),
411        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
412        Err(err) => Err(HostlibError::Backend {
413            builtin: DISCARD_BUILTIN,
414            message: format!("remove staged session {}: {err}", dir.display()),
415        }),
416    }
417}
418
419pub(crate) fn read(
420    path: &Path,
421    explicit_session_id: Option<&str>,
422) -> Option<std::io::Result<Vec<u8>>> {
423    let session_id = active_session_id(explicit_session_id)?;
424    let mut guard = lock_sessions();
425    let state = state_for_locked(&mut guard, &session_id, None).ok()?;
426    let result = if state.mode == FsMode::Staged {
427        overlay_read(&state, path)
428    } else {
429        None
430    };
431    guard.insert(session_id, state);
432    result
433}
434
435pub(crate) fn read_to_string(
436    path: &Path,
437    explicit_session_id: Option<&str>,
438) -> Option<std::io::Result<String>> {
439    read(path, explicit_session_id).map(|result| {
440        result.and_then(|bytes| {
441            String::from_utf8(bytes).map_err(|err| {
442                std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())
443            })
444        })
445    })
446}
447
448pub(crate) fn read_dir(
449    path: &Path,
450    explicit_session_id: Option<&str>,
451) -> Option<std::io::Result<Vec<OverlayDirEntry>>> {
452    let session_id = active_session_id(explicit_session_id)?;
453    let mut guard = lock_sessions();
454    let state = state_for_locked(&mut guard, &session_id, None).ok()?;
455    let result = if state.mode == FsMode::Staged {
456        Some(overlay_read_dir(&state, path))
457    } else {
458        None
459    };
460    guard.insert(session_id, state);
461    result
462}
463
464pub(crate) fn stage_write_or_none(
465    builtin: &'static str,
466    path: &Path,
467    bytes: &[u8],
468    create_parents: bool,
469    overwrite: bool,
470    explicit_session_id: Option<&str>,
471) -> Result<Option<WriteOutcome>, HostlibError> {
472    let Some(session_id) = active_session_id(explicit_session_id) else {
473        return Ok(None);
474    };
475    let mut guard = lock_sessions();
476    let mut state = state_for_locked(&mut guard, &session_id, None)?;
477    if state.mode != FsMode::Staged {
478        guard.insert(session_id, state);
479        return Ok(None);
480    }
481
482    let key = normalize_logical(path);
483    let existed = overlay_exists(&state, &key);
484    if existed && !overwrite {
485        guard.insert(session_id, state);
486        return Err(HostlibError::Backend {
487            builtin,
488            message: format!("`{}` exists and overwrite=false", key.display()),
489        });
490    }
491    if !create_parents && !parent_exists(&state, &key) {
492        guard.insert(session_id, state);
493        return Err(HostlibError::Backend {
494            builtin,
495            message: format!("parent directory for `{}` does not exist", key.display()),
496        });
497    }
498
499    let hash = write_body(&state, bytes).map_err(|err| HostlibError::Backend {
500        builtin,
501        message: err,
502    })?;
503    state.entries.insert(
504        key.clone(),
505        StagedEntry::Write {
506            body_hash: hash,
507            len: bytes.len() as u64,
508            created_at_ms: now_ms(),
509        },
510    );
511    persist_state(&state, "write", Some(&key)).map_err(|err| HostlibError::Backend {
512        builtin,
513        message: err,
514    })?;
515    emit_staged_update(&state);
516    guard.insert(session_id, state);
517    Ok(Some(WriteOutcome {
518        created: !existed,
519        bytes_written: bytes.len(),
520    }))
521}
522
523pub(crate) fn stage_delete_or_none(
524    builtin: &'static str,
525    path: &Path,
526    recursive: bool,
527    explicit_session_id: Option<&str>,
528) -> Result<Option<bool>, HostlibError> {
529    let Some(session_id) = active_session_id(explicit_session_id) else {
530        return Ok(None);
531    };
532    let mut guard = lock_sessions();
533    let mut state = state_for_locked(&mut guard, &session_id, None)?;
534    if state.mode != FsMode::Staged {
535        guard.insert(session_id, state);
536        return Ok(None);
537    }
538
539    let key = normalize_logical(path);
540    let staged_targets = staged_paths_under(&state, &key);
541    let disk_exists = key.exists();
542    if !disk_exists && staged_targets.is_empty() {
543        guard.insert(session_id, state);
544        return Ok(Some(false));
545    }
546
547    if !disk_exists {
548        for staged in staged_targets {
549            state.entries.remove(&staged);
550        }
551    } else {
552        validate_delete_shape(builtin, &key, recursive)?;
553        for staged in staged_targets {
554            state.entries.remove(&staged);
555        }
556        state.entries.insert(
557            key.clone(),
558            StagedEntry::Delete {
559                recursive,
560                created_at_ms: now_ms(),
561            },
562        );
563    }
564    persist_state(&state, "delete", Some(&key)).map_err(|err| HostlibError::Backend {
565        builtin,
566        message: err,
567    })?;
568    emit_staged_update(&state);
569    guard.insert(session_id, state);
570    Ok(Some(true))
571}
572
573/// Outcome of one [`safe_text_patch`] call. `applied` says whether the
574/// on-disk (or staged-overlay) bytes changed; `result` carries the
575/// structured discriminant used by the wire/JSON shape.
576#[derive(Clone, Debug)]
577pub struct SafeTextPatchOutcome {
578    /// Discriminant: `"applied"`, `"stale_base"`, or `"no_op"`.
579    pub result: SafeTextPatchResult,
580    /// `sha256:HEX` of the pre-image (overlay-aware) the call observed.
581    pub current_hash: String,
582    /// `sha256:HEX` of the requested post-image.
583    pub after_hash: String,
584    /// `true` when the file did not exist before the call.
585    pub created: bool,
586    /// Bytes written; `0` on `stale_base` or `no_op`.
587    pub bytes_written: usize,
588}
589
590/// Discriminant for a [`safe_text_patch`] outcome.
591#[derive(Clone, Copy, Debug, Eq, PartialEq)]
592pub enum SafeTextPatchResult {
593    /// Pre-image hash matched (or no expected hash supplied) and the
594    /// post-image differs from the pre-image — bytes were written.
595    Applied,
596    /// `expected_hash` did not match the observed pre-image hash; no
597    /// bytes were written. Callers should re-read and retry.
598    StaleBase,
599    /// Pre-image hash matched and the post-image equals the pre-image —
600    /// skipped the write to avoid spurious timestamps and overlay churn.
601    NoOp,
602}
603
604impl SafeTextPatchResult {
605    fn as_str(self) -> &'static str {
606        match self {
607            Self::Applied => "applied",
608            Self::StaleBase => "stale_base",
609            Self::NoOp => "no_op",
610        }
611    }
612}
613
614/// Format `bytes` as the `sha256:HEX` label used in `before_sha256` /
615/// `after_sha256` / `current_hash` / `expected_hash` everywhere in the
616/// safe-text-patch surface.
617fn hash_label(bytes: &[u8]) -> String {
618    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
619}
620
621/// Atomic compare-and-swap-style text write.
622///
623/// Reads the current bytes at `path` through the staged-fs overlay (when a
624/// session is active) so concurrent agent edits see each other's pending
625/// writes. If `expected_hash` is supplied and differs from the observed
626/// `sha256:HEX`, returns `SafeTextPatchResult::StaleBase` without
627/// mutating any state. On a hash match the post-image is written through
628/// the same overlay path, keeping the read and the write atomic with
629/// respect to other staged-fs consumers in the same process.
630///
631/// Atomicity:
632///
633/// - When a session is in staged mode, the read, hash check, and write
634///   all happen under a single acquisition of the sessions mutex, so a
635///   sibling thread cannot stage a write into the window between the
636///   pre-image snapshot and the commit.
637/// - When the call routes through disk (no active session, or session in
638///   immediate mode), a canonical-path file lock spans the read, hash check,
639///   and atomic rename. Competing Harn processes therefore observe the winner's
640///   post-image and return `stale_base` instead of silently overwriting it.
641pub fn safe_text_patch(
642    path: &Path,
643    content: &str,
644    expected_hash: Option<&str>,
645    session_id: Option<&str>,
646    create_parents: bool,
647    overwrite: bool,
648) -> Result<SafeTextPatchOutcome, HostlibError> {
649    let new_bytes = content.as_bytes();
650    let after_hash = hash_label(new_bytes);
651
652    if let Some(outcome) = safe_text_patch_staged(
653        path,
654        new_bytes,
655        expected_hash,
656        session_id,
657        create_parents,
658        overwrite,
659        &after_hash,
660    )? {
661        return Ok(outcome);
662    }
663
664    safe_text_patch_disk(
665        path,
666        new_bytes,
667        expected_hash,
668        create_parents,
669        overwrite,
670        after_hash,
671    )
672}
673
674/// Atomic CAS path for a session in `staged` mode. Holds the sessions
675/// mutex through the entire read → hash → check → write so concurrent
676/// agents in the same process cannot race the snapshot. Returns `None`
677/// when no session is active or the session is in `immediate` mode, so
678/// the caller can fall through to the disk path.
679#[allow(clippy::too_many_arguments)]
680fn safe_text_patch_staged(
681    path: &Path,
682    new_bytes: &[u8],
683    expected_hash: Option<&str>,
684    session_id: Option<&str>,
685    create_parents: bool,
686    overwrite: bool,
687    after_hash: &str,
688) -> Result<Option<SafeTextPatchOutcome>, HostlibError> {
689    let Some(session) = active_session_id(session_id) else {
690        return Ok(None);
691    };
692    let mut guard = lock_sessions();
693    let mut state = state_for_locked(&mut guard, &session, None)?;
694    if state.mode != FsMode::Staged {
695        guard.insert(session, state);
696        return Ok(None);
697    }
698
699    let key = normalize_logical(path);
700    let (existing_bytes, existed) = match overlay_read(&state, path) {
701        Some(Ok(bytes)) => (bytes, true),
702        Some(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
703        Some(Err(err)) => {
704            guard.insert(session, state);
705            return Err(HostlibError::Backend {
706                builtin: SAFE_TEXT_PATCH_BUILTIN,
707                message: format!("read `{}`: {err}", path.display()),
708            });
709        }
710        None => match stdfs::read(path) {
711            Ok(bytes) => (bytes, true),
712            Err(err) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
713            Err(err) => {
714                guard.insert(session, state);
715                return Err(HostlibError::Backend {
716                    builtin: SAFE_TEXT_PATCH_BUILTIN,
717                    message: format!("read `{}`: {err}", path.display()),
718                });
719            }
720        },
721    };
722    let current_hash = hash_label(&existing_bytes);
723
724    if let Some(expected) = expected_hash {
725        if expected != current_hash {
726            guard.insert(session, state);
727            return Ok(Some(SafeTextPatchOutcome {
728                result: SafeTextPatchResult::StaleBase,
729                current_hash,
730                after_hash: after_hash.to_string(),
731                created: false,
732                bytes_written: 0,
733            }));
734        }
735    }
736
737    if existed && existing_bytes == new_bytes {
738        guard.insert(session, state);
739        return Ok(Some(SafeTextPatchOutcome {
740            result: SafeTextPatchResult::NoOp,
741            current_hash,
742            after_hash: after_hash.to_string(),
743            created: false,
744            bytes_written: 0,
745        }));
746    }
747
748    let overlay_existed = overlay_exists(&state, &key);
749    if overlay_existed && !overwrite {
750        guard.insert(session, state);
751        return Err(HostlibError::Backend {
752            builtin: SAFE_TEXT_PATCH_BUILTIN,
753            message: format!("`{}` exists and overwrite=false", key.display()),
754        });
755    }
756    if !create_parents && !parent_exists(&state, &key) {
757        guard.insert(session, state);
758        return Err(HostlibError::Backend {
759            builtin: SAFE_TEXT_PATCH_BUILTIN,
760            message: format!("parent directory for `{}` does not exist", key.display()),
761        });
762    }
763
764    let body_hash = write_body(&state, new_bytes).map_err(|err| HostlibError::Backend {
765        builtin: SAFE_TEXT_PATCH_BUILTIN,
766        message: err,
767    })?;
768    state.entries.insert(
769        key.clone(),
770        StagedEntry::Write {
771            body_hash,
772            len: new_bytes.len() as u64,
773            created_at_ms: now_ms(),
774        },
775    );
776    persist_state(&state, "safe_text_patch", Some(&key)).map_err(|err| HostlibError::Backend {
777        builtin: SAFE_TEXT_PATCH_BUILTIN,
778        message: err,
779    })?;
780    emit_staged_update(&state);
781    guard.insert(session, state);
782
783    Ok(Some(SafeTextPatchOutcome {
784        result: SafeTextPatchResult::Applied,
785        current_hash,
786        after_hash: after_hash.to_string(),
787        created: !existed,
788        bytes_written: new_bytes.len(),
789    }))
790}
791
792/// Disk path for callers without an active staged session. Uses
793/// `atomic_write` so the post-image lands via rename-into-place rather
794/// than an open/truncate/write/close sequence — readers either see the
795/// pre-image or the post-image, never a torn write.
796fn safe_text_patch_disk(
797    path: &Path,
798    new_bytes: &[u8],
799    expected_hash: Option<&str>,
800    create_parents: bool,
801    overwrite: bool,
802    after_hash: String,
803) -> Result<SafeTextPatchOutcome, HostlibError> {
804    let lock_root = safe_text_patch_lock_root();
805    safe_text_patch_disk_with_lock_root(
806        path,
807        new_bytes,
808        expected_hash,
809        create_parents,
810        overwrite,
811        after_hash,
812        &lock_root,
813    )
814}
815
816#[allow(clippy::too_many_arguments)]
817fn safe_text_patch_disk_with_lock_root(
818    path: &Path,
819    new_bytes: &[u8],
820    expected_hash: Option<&str>,
821    create_parents: bool,
822    overwrite: bool,
823    after_hash: String,
824    lock_root: &Path,
825) -> Result<SafeTextPatchOutcome, HostlibError> {
826    let _lock = acquire_safe_text_patch_lock(path, lock_root)?;
827    safe_text_patch_disk_locked(
828        path,
829        new_bytes,
830        expected_hash,
831        create_parents,
832        overwrite,
833        after_hash,
834    )
835}
836
837fn safe_text_patch_disk_locked(
838    path: &Path,
839    new_bytes: &[u8],
840    expected_hash: Option<&str>,
841    create_parents: bool,
842    overwrite: bool,
843    after_hash: String,
844) -> Result<SafeTextPatchOutcome, HostlibError> {
845    let (existing_bytes, existed) = match stdfs::read(path) {
846        Ok(bytes) => (bytes, true),
847        Err(err) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
848        Err(err) => {
849            return Err(HostlibError::Backend {
850                builtin: SAFE_TEXT_PATCH_BUILTIN,
851                message: format!("read `{}`: {err}", path.display()),
852            });
853        }
854    };
855    let current_hash = hash_label(&existing_bytes);
856
857    if let Some(expected) = expected_hash {
858        if expected != current_hash {
859            return Ok(SafeTextPatchOutcome {
860                result: SafeTextPatchResult::StaleBase,
861                current_hash,
862                after_hash,
863                created: false,
864                bytes_written: 0,
865            });
866        }
867    }
868
869    if existed && existing_bytes == new_bytes {
870        return Ok(SafeTextPatchOutcome {
871            result: SafeTextPatchResult::NoOp,
872            current_hash,
873            after_hash,
874            created: false,
875            bytes_written: 0,
876        });
877    }
878    if existed && !overwrite {
879        return Err(HostlibError::Backend {
880            builtin: SAFE_TEXT_PATCH_BUILTIN,
881            message: format!("`{}` exists and overwrite=false", path.display()),
882        });
883    }
884    if !create_parents {
885        if let Some(parent) = path.parent() {
886            if !parent.as_os_str().is_empty() && !parent.is_dir() {
887                return Err(HostlibError::Backend {
888                    builtin: SAFE_TEXT_PATCH_BUILTIN,
889                    message: format!(
890                        "parent directory for `{}` does not exist (pass create_parents=true to mkdir)",
891                        path.display()
892                    ),
893                });
894            }
895        }
896    }
897
898    crate::fs_snapshot::auto_capture_for_write(SAFE_TEXT_PATCH_BUILTIN, path);
899    atomic_write(path, new_bytes).map_err(|err| HostlibError::Backend {
900        builtin: SAFE_TEXT_PATCH_BUILTIN,
901        message: format!("write `{}`: {err}", path.display()),
902    })?;
903
904    Ok(SafeTextPatchOutcome {
905        result: SafeTextPatchResult::Applied,
906        current_hash,
907        after_hash,
908        created: !existed,
909        bytes_written: new_bytes.len(),
910    })
911}
912
913/// Read the pre-image through the staged-fs overlay (when active),
914/// falling back to disk. Returns `(bytes, existed_on_disk_or_overlay)`.
915/// `builtin` is the caller's tag — used so backend errors point at the
916/// right builtin name in diagnostics.
917fn read_existing(
918    builtin: &'static str,
919    path: &Path,
920    session_id: Option<&str>,
921) -> Result<(Vec<u8>, bool), HostlibError> {
922    if let Some(result) = read(path, session_id) {
923        return match result {
924            Ok(bytes) => Ok((bytes, true)),
925            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
926            Err(err) => Err(HostlibError::Backend {
927                builtin,
928                message: format!("read `{}`: {err}", path.display()),
929            }),
930        };
931    }
932    match stdfs::read(path) {
933        Ok(bytes) => Ok((bytes, true)),
934        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
935        Err(err) => Err(HostlibError::Backend {
936            builtin,
937            message: format!("read `{}`: {err}", path.display()),
938        }),
939    }
940}
941
942fn read_text_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
943    let raw = dict_arg(READ_TEXT_BUILTIN, args)?;
944    let dict = raw.as_ref();
945    let path_str = require_string(READ_TEXT_BUILTIN, dict, "path")?;
946    let session_id = optional_string(READ_TEXT_BUILTIN, dict, "session_id")?;
947    let path = resolve_host_path(&path_str);
948    enforce_path_scope(READ_TEXT_BUILTIN, &path, FsAccess::Read)?;
949
950    let (bytes, existed) = read_existing(READ_TEXT_BUILTIN, &path, session_id.as_deref())?;
951    let hash = hash_label(&bytes);
952    let content = match std::str::from_utf8(&bytes) {
953        Ok(s) => s.to_string(),
954        Err(err) => {
955            return Err(HostlibError::Backend {
956                builtin: READ_TEXT_BUILTIN,
957                message: format!("`{path_str}` is not valid UTF-8: {err}"),
958            });
959        }
960    };
961    let bytes_len = bytes.len() as i64;
962    Ok(build_dict([
963        ("path", str_value(&path_str)),
964        ("content", str_value(&content)),
965        ("sha256", str_value(&hash)),
966        ("size", VmValue::Int(bytes_len)),
967        ("exists", VmValue::Bool(existed)),
968    ]))
969}
970
971fn safe_text_patch_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
972    let raw = dict_arg(SAFE_TEXT_PATCH_BUILTIN, args)?;
973    let dict = raw.as_ref();
974
975    let path_str = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "path")?;
976    let content = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "content")?;
977    let expected_hash = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "expected_hash")?;
978    let session_id = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "session_id")?;
979    let create_parents = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "create_parents", true)?;
980    let overwrite = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "overwrite", true)?;
981
982    let path = resolve_host_path(&path_str);
983    enforce_path_scope(SAFE_TEXT_PATCH_BUILTIN, &path, FsAccess::Write)?;
984    let outcome = safe_text_patch(
985        &path,
986        &content,
987        expected_hash.as_deref(),
988        session_id.as_deref(),
989        create_parents,
990        overwrite,
991    )?;
992
993    let entries: Vec<(&'static str, VmValue)> = vec![
994        ("path", str_value(&path_str)),
995        ("result", str_value(outcome.result.as_str())),
996        (
997            "applied",
998            VmValue::Bool(outcome.result == SafeTextPatchResult::Applied),
999        ),
1000        (
1001            "stale_base",
1002            VmValue::Bool(outcome.result == SafeTextPatchResult::StaleBase),
1003        ),
1004        ("current_hash", str_value(&outcome.current_hash)),
1005        ("before_sha256", str_value(&outcome.current_hash)),
1006        ("after_sha256", str_value(&outcome.after_hash)),
1007        ("created", VmValue::Bool(outcome.created)),
1008        ("bytes_written", VmValue::Int(outcome.bytes_written as i64)),
1009        (
1010            "expected_hash",
1011            match expected_hash.as_deref() {
1012                Some(hash) => str_value(hash),
1013                None => VmValue::Nil,
1014            },
1015        ),
1016    ];
1017    Ok(build_dict(entries))
1018}
1019
1020fn emit_safe_text_patch_result_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1021    let raw = dict_arg(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, args)?;
1022    let dict = raw.as_ref();
1023
1024    let path = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "path")?;
1025    let result = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "result")?;
1026    let hunks_count = optional_int(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "hunks_count", 0)?;
1027    let bytes_written = optional_int(
1028        EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
1029        dict,
1030        "bytes_written",
1031        0,
1032    )?;
1033    let failed_hunk_index = match dict.get("failed_hunk_index") {
1034        None | Some(VmValue::Nil) => None,
1035        Some(VmValue::Int(n)) if *n >= 0 => Some(*n as usize),
1036        Some(other) => {
1037            return Err(HostlibError::InvalidParameter {
1038                builtin: EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
1039                param: "failed_hunk_index",
1040                message: format!("expected non-negative integer, got {}", other.type_name()),
1041            });
1042        }
1043    };
1044    let session_id = optional_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "session_id")?
1045        .or_else(harn_vm::agent_sessions::current_session_id);
1046
1047    if let Some(session_id) = session_id.filter(|s| !s.trim().is_empty()) {
1048        harn_vm::agent_events::emit_event(&AgentEvent::SafeTextPatchResult {
1049            session_id,
1050            path,
1051            result,
1052            hunks_count: hunks_count.max(0) as usize,
1053            bytes_written: bytes_written.max(0) as u64,
1054            failed_hunk_index,
1055        });
1056        Ok(VmValue::Bool(true))
1057    } else {
1058        // Silently no-op when no session is active — telemetry without a
1059        // session has nowhere to route. Caller can opt in by always
1060        // passing session_id explicitly.
1061        Ok(VmValue::Bool(false))
1062    }
1063}
1064
1065fn set_mode_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1066    let raw = dict_arg(SET_MODE_BUILTIN, args)?;
1067    let dict = raw.as_ref();
1068    let session_id = require_string(SET_MODE_BUILTIN, dict, "session_id")?;
1069    let mode = FsMode::parse(
1070        SET_MODE_BUILTIN,
1071        &require_string(SET_MODE_BUILTIN, dict, "mode")?,
1072    )?;
1073    let root =
1074        optional_string(SET_MODE_BUILTIN, dict, "root")?.map(|path| resolve_host_path(&path));
1075    let result = set_mode(&session_id, mode, root.as_deref())?;
1076    Ok(build_dict([(
1077        "previous_mode",
1078        str_value(result.previous_mode.as_str()),
1079    )]))
1080}
1081
1082fn staged_status_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1083    let raw = dict_arg(STATUS_BUILTIN, args)?;
1084    let session_id = require_string(STATUS_BUILTIN, raw.as_ref(), "session_id")?;
1085    Ok(status_to_value(staged_status(&session_id)?))
1086}
1087
1088fn commit_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1089    let raw = dict_arg(COMMIT_BUILTIN, args)?;
1090    let dict = raw.as_ref();
1091    let session_id = require_string(COMMIT_BUILTIN, dict, "session_id")?;
1092    let paths = optional_string_list(COMMIT_BUILTIN, dict, "paths")?;
1093    Ok(commit_result_to_value(commit_staged(&session_id, &paths)?))
1094}
1095
1096fn discard_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1097    let raw = dict_arg(DISCARD_BUILTIN, args)?;
1098    let dict = raw.as_ref();
1099    let session_id = require_string(DISCARD_BUILTIN, dict, "session_id")?;
1100    let paths = optional_string_list(DISCARD_BUILTIN, dict, "paths")?;
1101    Ok(discard_result_to_value(discard_staged(
1102        &session_id,
1103        &paths,
1104    )?))
1105}
1106
1107fn state_for_locked(
1108    guard: &mut BTreeMap<String, SessionState>,
1109    session_id: &str,
1110    root: Option<PathBuf>,
1111) -> Result<SessionState, HostlibError> {
1112    if let Some(existing) = guard.get(session_id) {
1113        let mut state = existing.clone();
1114        if let Some(root) = root {
1115            if state.entries.is_empty() {
1116                state.root = root;
1117            }
1118        }
1119        return Ok(state);
1120    }
1121    let state = load_state(session_id, root).map_err(|err| HostlibError::Backend {
1122        builtin: SET_MODE_BUILTIN,
1123        message: err,
1124    })?;
1125    Ok(state)
1126}
1127
1128fn load_state(session_id: &str, root: Option<PathBuf>) -> Result<SessionState, String> {
1129    let root = root.unwrap_or_else(default_root);
1130    let manifest_path = manifest_path(&root, session_id);
1131    if manifest_path.exists() {
1132        let text = stdfs::read_to_string(&manifest_path)
1133            .map_err(|err| format!("read {}: {err}", manifest_path.display()))?;
1134        let manifest: Manifest = serde_json::from_str(&text)
1135            .map_err(|err| format!("parse {}: {err}", manifest_path.display()))?;
1136        if manifest.version != MANIFEST_VERSION {
1137            return Err(format!(
1138                "unsupported staged fs manifest version {} in {}",
1139                manifest.version,
1140                manifest_path.display()
1141            ));
1142        }
1143        if manifest.session_id != session_id {
1144            return Err(format!(
1145                "staged fs manifest session id mismatch in {}",
1146                manifest_path.display()
1147            ));
1148        }
1149        return Ok(SessionState {
1150            session_id: manifest.session_id,
1151            mode: manifest.mode,
1152            root: normalize_logical(Path::new(&manifest.root)),
1153            entries: manifest
1154                .entries
1155                .into_iter()
1156                .map(|(path, entry)| (normalize_logical(Path::new(&path)), entry))
1157                .collect(),
1158        });
1159    }
1160    Ok(SessionState {
1161        session_id: session_id.to_string(),
1162        mode: FsMode::Immediate,
1163        root,
1164        entries: BTreeMap::new(),
1165    })
1166}
1167
1168fn persist_state(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1169    let dir = session_dir(&state.root, &state.session_id);
1170    stdfs::create_dir_all(dir.join("bodies"))
1171        .map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1172    let manifest = Manifest {
1173        version: MANIFEST_VERSION,
1174        session_id: state.session_id.clone(),
1175        mode: state.mode,
1176        root: state.root.to_string_lossy().into_owned(),
1177        entries: state
1178            .entries
1179            .iter()
1180            .map(|(path, entry)| (path.to_string_lossy().into_owned(), entry.clone()))
1181            .collect(),
1182    };
1183    let bytes = serde_json::to_vec_pretty(&manifest)
1184        .map_err(|err| format!("serialize staged manifest: {err}"))?;
1185    atomic_write(&manifest_path(&state.root, &state.session_id), &bytes)?;
1186    append_journal(state, op, path)?;
1187    prune_unreferenced_bodies(state);
1188    Ok(())
1189}
1190
1191fn append_journal(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1192    let dir = session_dir(&state.root, &state.session_id);
1193    stdfs::create_dir_all(&dir).map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1194    let line = serde_json::to_string(&serde_json::json!({
1195        "ts_ms": now_ms(),
1196        "op": op,
1197        "path": path.map(|path| path.to_string_lossy().into_owned()), // agent-path-ok: on-disk journal.jsonl audit line, never returned to the agent
1198        "pending_count": state.entries.len(),
1199    }))
1200    .map_err(|err| format!("serialize staged journal: {err}"))?;
1201    let mut file = stdfs::OpenOptions::new()
1202        .create(true)
1203        .append(true)
1204        .open(dir.join("journal.jsonl"))
1205        .map_err(|err| format!("open staged journal: {err}"))?;
1206    writeln!(file, "{line}").map_err(|err| format!("write staged journal: {err}"))
1207}
1208
1209fn write_body(state: &SessionState, bytes: &[u8]) -> Result<String, String> {
1210    let hash = hex::encode(Sha256::digest(bytes));
1211    let path = session_dir(&state.root, &state.session_id)
1212        .join("bodies")
1213        .join(&hash);
1214    if !path.exists() {
1215        atomic_write(&path, bytes)?;
1216    }
1217    Ok(hash)
1218}
1219
1220fn read_body(state: &SessionState, hash: &str) -> std::io::Result<Vec<u8>> {
1221    stdfs::read(
1222        session_dir(&state.root, &state.session_id)
1223            .join("bodies")
1224            .join(hash),
1225    )
1226}
1227
1228fn prune_unreferenced_bodies(state: &SessionState) {
1229    let live: BTreeSet<String> = state
1230        .entries
1231        .values()
1232        .filter_map(|entry| match entry {
1233            StagedEntry::Write { body_hash, .. } => Some(body_hash.clone()),
1234            StagedEntry::Delete { .. } => None,
1235        })
1236        .collect();
1237    let body_dir = session_dir(&state.root, &state.session_id).join("bodies");
1238    let Ok(entries) = stdfs::read_dir(&body_dir) else {
1239        return;
1240    };
1241    for entry in entries.flatten() {
1242        let name = entry.file_name().to_string_lossy().into_owned();
1243        if !live.contains(&name) {
1244            let _ = stdfs::remove_file(entry.path());
1245        }
1246    }
1247}
1248
1249fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
1250    if let Some(parent) = path.parent() {
1251        stdfs::create_dir_all(parent)
1252            .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
1253    }
1254    let tmp = path.with_extension(format!("tmp-{}-{}", std::process::id(), now_ms()));
1255    stdfs::write(&tmp, bytes).map_err(|err| format!("write {}: {err}", tmp.display()))?;
1256    match stdfs::rename(&tmp, path) {
1257        Ok(()) => Ok(()),
1258        Err(err) => {
1259            let _ = stdfs::remove_file(path);
1260            stdfs::rename(&tmp, path).map_err(|retry| {
1261                format!(
1262                    "rename {} to {}: {err}; retry: {retry}",
1263                    tmp.display(),
1264                    path.display()
1265                )
1266            })
1267        }
1268    }
1269}
1270
1271fn commit_entry(state: &SessionState, path: &Path, entry: &StagedEntry) -> Result<(), String> {
1272    match entry {
1273        StagedEntry::Write { body_hash, .. } => {
1274            let bytes = read_body(state, body_hash)
1275                .map_err(|err| format!("read staged body for {}: {err}", path.display()))?;
1276            atomic_write(path, &bytes)
1277        }
1278        StagedEntry::Delete { recursive, .. } => match stdfs::symlink_metadata(path) {
1279            Ok(metadata) if metadata.is_dir() => {
1280                if *recursive {
1281                    stdfs::remove_dir_all(path)
1282                        .map_err(|err| format!("remove_dir_all {}: {err}", path.display()))
1283                } else {
1284                    stdfs::remove_dir(path)
1285                        .map_err(|err| format!("remove_dir {}: {err}", path.display()))
1286                }
1287            }
1288            Ok(_) => stdfs::remove_file(path)
1289                .map_err(|err| format!("remove_file {}: {err}", path.display())),
1290            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1291            Err(err) => Err(format!("stat {}: {err}", path.display())),
1292        },
1293    }
1294}
1295
1296fn overlay_read(state: &SessionState, path: &Path) -> Option<std::io::Result<Vec<u8>>> {
1297    let key = normalize_logical(path);
1298    if let Some(entry) = state.entries.get(&key) {
1299        return Some(match entry {
1300            StagedEntry::Write { body_hash, .. } => read_body(state, body_hash),
1301            StagedEntry::Delete { .. } => Err(not_found(&key)),
1302        });
1303    }
1304    if deleted_ancestor(state, &key) {
1305        return Some(Err(not_found(&key)));
1306    }
1307    None
1308}
1309
1310fn overlay_read_dir(state: &SessionState, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
1311    let dir_key = normalize_logical(path);
1312    if matches!(state.entries.get(&dir_key), Some(StagedEntry::Write { .. }))
1313        || deleted_ancestor(state, &dir_key)
1314        || matches!(
1315            state.entries.get(&dir_key),
1316            Some(StagedEntry::Delete { .. })
1317        )
1318    {
1319        return Err(not_found(&dir_key));
1320    }
1321    if !path.exists() && !has_staged_descendant(state, &dir_key) {
1322        return Err(not_found(&dir_key));
1323    }
1324
1325    let mut entries: BTreeMap<String, OverlayDirEntry> = BTreeMap::new();
1326    if path.exists() {
1327        for entry in stdfs::read_dir(path)? {
1328            let entry = entry?;
1329            let name = entry.file_name().to_string_lossy().into_owned();
1330            let file_type = entry.file_type().ok();
1331            let metadata = entry.metadata().ok();
1332            entries.insert(
1333                name.clone(),
1334                OverlayDirEntry {
1335                    name,
1336                    is_dir: file_type.is_some_and(|ty| ty.is_dir()),
1337                    is_symlink: file_type.is_some_and(|ty| ty.is_symlink()),
1338                    size: metadata.map(|m| m.len()).unwrap_or(0),
1339                },
1340            );
1341        }
1342    }
1343
1344    for (path, entry) in &state.entries {
1345        let Some(name) = overlay_child_name(path, &dir_key) else {
1346            continue;
1347        };
1348        match entry {
1349            StagedEntry::Write { len, .. } => {
1350                let is_dir = path.parent() != Some(dir_key.as_path());
1351                entries.insert(
1352                    name.clone(),
1353                    OverlayDirEntry {
1354                        name,
1355                        is_dir,
1356                        is_symlink: false,
1357                        size: if is_dir { 0 } else { *len },
1358                    },
1359                );
1360            }
1361            StagedEntry::Delete { .. } => {
1362                if path.parent() == Some(dir_key.as_path()) {
1363                    entries.remove(&name);
1364                }
1365            }
1366        }
1367    }
1368
1369    Ok(entries.into_values().collect())
1370}
1371
1372fn overlay_child_name(path: &Path, dir: &Path) -> Option<String> {
1373    let suffix = path.strip_prefix(dir).ok()?;
1374    let mut components = suffix.components();
1375    let first = components.next()?;
1376    match first {
1377        Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1378        _ => None,
1379    }
1380}
1381
1382fn overlay_exists(state: &SessionState, path: &Path) -> bool {
1383    if let Some(entry) = state.entries.get(path) {
1384        return matches!(entry, StagedEntry::Write { .. });
1385    }
1386    if deleted_ancestor(state, path) {
1387        return false;
1388    }
1389    if has_staged_descendant(state, path) {
1390        return true;
1391    }
1392    path.exists()
1393}
1394
1395fn parent_exists(state: &SessionState, path: &Path) -> bool {
1396    let Some(parent) = path.parent() else {
1397        return true;
1398    };
1399    if parent.as_os_str().is_empty() {
1400        return true;
1401    }
1402    if let Some(entry) = state.entries.get(parent) {
1403        return !matches!(entry, StagedEntry::Delete { .. });
1404    }
1405    if deleted_ancestor(state, parent) {
1406        return false;
1407    }
1408    if has_staged_descendant(state, parent) {
1409        return true;
1410    }
1411    parent.is_dir()
1412}
1413
1414fn deleted_ancestor(state: &SessionState, path: &Path) -> bool {
1415    state.entries.iter().any(|(candidate, entry)| {
1416        matches!(entry, StagedEntry::Delete { .. })
1417            && path != candidate.as_path()
1418            && path.starts_with(candidate)
1419    })
1420}
1421
1422fn has_staged_descendant(state: &SessionState, path: &Path) -> bool {
1423    state.entries.iter().any(|(candidate, entry)| {
1424        matches!(entry, StagedEntry::Write { .. })
1425            && candidate != path
1426            && candidate.starts_with(path)
1427    })
1428}
1429
1430fn staged_paths_under(state: &SessionState, path: &Path) -> Vec<PathBuf> {
1431    state
1432        .entries
1433        .keys()
1434        .filter(|candidate| *candidate == path || candidate.starts_with(path))
1435        .cloned()
1436        .collect()
1437}
1438
1439fn validate_delete_shape(
1440    builtin: &'static str,
1441    path: &Path,
1442    recursive: bool,
1443) -> Result<(), HostlibError> {
1444    let Ok(metadata) = stdfs::symlink_metadata(path) else {
1445        return Ok(());
1446    };
1447    if metadata.is_dir() && !recursive {
1448        let mut entries = stdfs::read_dir(path).map_err(|err| HostlibError::Backend {
1449            builtin,
1450            message: format!("read_dir `{}`: {err}", path.display()),
1451        })?;
1452        if entries.next().is_some() {
1453            return Err(HostlibError::Backend {
1454                builtin,
1455                message: format!(
1456                    "remove_dir `{}` (pass recursive=true to delete non-empty dirs): directory not empty",
1457                    path.display()
1458                ),
1459            });
1460        }
1461    }
1462    Ok(())
1463}
1464
1465fn status_from_state(state: &SessionState) -> StagedStatus {
1466    let now = now_ms();
1467    let mut pending_writes = Vec::new();
1468    let mut total_bytes_pending = 0u64;
1469    let mut oldest = None;
1470    for (path, entry) in &state.entries {
1471        total_bytes_pending = total_bytes_pending.saturating_add(entry.body_len());
1472        oldest = Some(oldest.map_or(entry.created_at_ms(), |old: i64| {
1473            old.min(entry.created_at_ms())
1474        }));
1475        let (kind, bytes_added, bytes_removed) = match entry {
1476            StagedEntry::Write { len, .. } => ("write", *len, disk_size(path).unwrap_or(0)),
1477            StagedEntry::Delete { .. } => ("delete", 0, disk_size(path).unwrap_or(0)),
1478        };
1479        pending_writes.push(PendingWrite {
1480            path: to_agent_path(path),
1481            kind,
1482            bytes_added,
1483            bytes_removed,
1484        });
1485    }
1486    StagedStatus {
1487        pending_writes,
1488        total_bytes_pending,
1489        oldest_pending_age_ms: oldest.map(|old| now.saturating_sub(old)).unwrap_or(0),
1490    }
1491}
1492
1493fn disk_size(path: &Path) -> Option<u64> {
1494    let metadata = stdfs::symlink_metadata(path).ok()?;
1495    if metadata.is_file() {
1496        return Some(metadata.len());
1497    }
1498    if metadata.is_dir() {
1499        let mut total = 0u64;
1500        for entry in walkdir::WalkDir::new(path)
1501            .into_iter()
1502            .filter_map(Result::ok)
1503        {
1504            if let Ok(metadata) = entry.metadata() {
1505                if metadata.is_file() {
1506                    total = total.saturating_add(metadata.len());
1507                }
1508            }
1509        }
1510        return Some(total);
1511    }
1512    Some(metadata.len())
1513}
1514
1515fn selected_paths(state: &SessionState, paths: &[String]) -> Vec<PathBuf> {
1516    if paths.is_empty() {
1517        return state.entries.keys().cloned().collect();
1518    }
1519    let selected: BTreeSet<PathBuf> = paths
1520        .iter()
1521        .map(|path| normalize_logical(Path::new(path)))
1522        .collect();
1523    state
1524        .entries
1525        .keys()
1526        .filter(|path| selected.contains(*path))
1527        .cloned()
1528        .collect()
1529}
1530
1531fn active_session_id(explicit: Option<&str>) -> Option<String> {
1532    explicit
1533        .map(str::to_string)
1534        .or_else(harn_vm::agent_sessions::current_session_id)
1535        .filter(|id| !id.trim().is_empty())
1536}
1537
1538fn validate_session_id(builtin: &'static str, session_id: &str) -> Result<(), HostlibError> {
1539    if session_id.trim().is_empty() {
1540        return Err(HostlibError::InvalidParameter {
1541            builtin,
1542            param: "session_id",
1543            message: "must not be empty".to_string(),
1544        });
1545    }
1546    Ok(())
1547}
1548
1549fn default_root() -> PathBuf {
1550    harn_vm::stdlib::process::execution_root_path()
1551}
1552
1553fn session_dir(root: &Path, session_id: &str) -> PathBuf {
1554    let mut dir = root.to_path_buf();
1555    for component in STATE_REL {
1556        dir.push(component);
1557    }
1558    dir.push(sanitize_component(session_id));
1559    dir
1560}
1561
1562fn manifest_path(root: &Path, session_id: &str) -> PathBuf {
1563    session_dir(root, session_id).join("manifest.json")
1564}
1565
1566fn sanitize_component(input: &str) -> String {
1567    let sanitized: String = input
1568        .chars()
1569        .map(|ch| match ch {
1570            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
1571            _ => '_',
1572        })
1573        .collect();
1574    // `.` is allowed inside a name, but a component that is empty or *only*
1575    // dots (`.`, `..`, `...`) is a path-traversal / current-dir token, not a
1576    // safe single component — `session_dir`'s `dir.push("..")` would escape
1577    // the staged-state root. Force the hashed form so the result is always a
1578    // genuine, traversal-free directory name.
1579    let is_dotted = sanitized.is_empty() || sanitized.bytes().all(|b| b == b'.');
1580    if sanitized == input && !is_dotted {
1581        sanitized
1582    } else {
1583        let hash = hex::encode(Sha256::digest(input.as_bytes()));
1584        format!("{sanitized}-{}", &hash[..12])
1585    }
1586}
1587
1588fn normalize_logical(path: &Path) -> PathBuf {
1589    let absolute = if path.is_absolute() {
1590        path.to_path_buf()
1591    } else {
1592        default_root().join(path)
1593    };
1594    let mut out = PathBuf::new();
1595    for component in absolute.components() {
1596        match component {
1597            Component::ParentDir => {
1598                out.pop();
1599            }
1600            Component::CurDir => {}
1601            other => out.push(other),
1602        }
1603    }
1604    out
1605}
1606
1607fn not_found(path: &Path) -> std::io::Error {
1608    std::io::Error::new(
1609        std::io::ErrorKind::NotFound,
1610        format!("staged fs: {} is deleted or absent", path.display()),
1611    )
1612}
1613
1614fn now_ms() -> i64 {
1615    std::time::SystemTime::now()
1616        .duration_since(std::time::UNIX_EPOCH)
1617        .map(|duration| duration.as_millis() as i64)
1618        .unwrap_or(0)
1619}
1620
1621fn emit_staged_update(state: &SessionState) {
1622    let status = status_from_state(state);
1623    harn_vm::agent_events::emit_event(&AgentEvent::StagedWritesPending {
1624        session_id: state.session_id.clone(),
1625        pending_count: status.pending_writes.len(),
1626        total_bytes: status.total_bytes_pending,
1627    });
1628}
1629
1630fn pending_write_to_value(write: PendingWrite) -> VmValue {
1631    build_dict([
1632        ("path", str_value(&write.path)),
1633        ("kind", str_value(write.kind)),
1634        ("bytes_added", VmValue::Int(write.bytes_added as i64)),
1635        ("bytes_removed", VmValue::Int(write.bytes_removed as i64)),
1636    ])
1637}
1638
1639fn status_to_value(status: StagedStatus) -> VmValue {
1640    build_dict([
1641        (
1642            "pending_writes",
1643            VmValue::List(Arc::new(
1644                status
1645                    .pending_writes
1646                    .into_iter()
1647                    .map(pending_write_to_value)
1648                    .collect(),
1649            )),
1650        ),
1651        (
1652            "total_bytes_pending",
1653            VmValue::Int(status.total_bytes_pending as i64),
1654        ),
1655        (
1656            "oldest_pending_age_ms",
1657            VmValue::Int(status.oldest_pending_age_ms),
1658        ),
1659    ])
1660}
1661
1662fn commit_result_to_value(result: CommitResult) -> VmValue {
1663    build_dict([
1664        (
1665            "committed_paths",
1666            VmValue::List(Arc::new(
1667                result
1668                    .committed_paths
1669                    .into_iter()
1670                    .map(|path| VmValue::String(arcstr::ArcStr::from(path)))
1671                    .collect(),
1672            )),
1673        ),
1674        (
1675            "failed_paths_with_reasons",
1676            VmValue::List(Arc::new(
1677                result
1678                    .failed_paths_with_reasons
1679                    .into_iter()
1680                    .map(|(path, reason)| {
1681                        build_dict([("path", str_value(&path)), ("reason", str_value(&reason))])
1682                    })
1683                    .collect(),
1684            )),
1685        ),
1686    ])
1687}
1688
1689fn discard_result_to_value(result: DiscardResult) -> VmValue {
1690    build_dict([(
1691        "discarded_paths",
1692        VmValue::List(Arc::new(
1693            result
1694                .discarded_paths
1695                .into_iter()
1696                .map(|path| VmValue::String(arcstr::ArcStr::from(path)))
1697                .collect(),
1698        )),
1699    )])
1700}
1701
1702#[cfg(test)]
1703mod staged_path_tests {
1704    use super::{set_mode, stage_write_or_none, staged_pending_paths, staged_status, FsMode};
1705    use tempfile::tempdir;
1706
1707    #[test]
1708    fn staged_pending_paths_preserve_native_filesystem_paths() {
1709        let dir = tempdir().expect("tempdir");
1710        let root = dir.path().canonicalize().expect("canonical tempdir");
1711        let path = root.join("src").join("lib.rs");
1712        let session_id = format!(
1713            "native-paths-{}-{}",
1714            std::process::id(),
1715            std::thread::current().name().unwrap_or("test")
1716        );
1717
1718        set_mode(&session_id, FsMode::Staged, Some(&root)).expect("set staged mode");
1719        stage_write_or_none(
1720            "test",
1721            &path,
1722            b"fn beta() {}\n",
1723            true,
1724            true,
1725            Some(&session_id),
1726        )
1727        .expect("stage write")
1728        .expect("staged write");
1729
1730        let native_paths = staged_pending_paths(&session_id).expect("pending paths");
1731        assert_eq!(
1732            native_paths,
1733            std::collections::BTreeSet::from([path.clone()])
1734        );
1735
1736        let status = staged_status(&session_id).expect("staged status");
1737        assert_eq!(status.pending_writes.len(), 1);
1738        assert_eq!(
1739            status.pending_writes[0].path,
1740            crate::tools::args::to_agent_path(&path)
1741        );
1742
1743        let _ = super::remove_session_state(&session_id, Some(&root));
1744    }
1745}
1746
1747#[cfg(test)]
1748mod safe_text_patch_lock_tests {
1749    use super::{
1750        acquire_safe_text_patch_lock, hash_label, open_safe_text_patch_lock,
1751        safe_text_patch_disk_locked, safe_text_patch_disk_with_lock_root,
1752        safe_text_patch_lock_root, scope_safe_text_patch_lock_root, SafeTextPatchResult,
1753    };
1754    use fs2::FileExt;
1755    use std::io::{BufRead, BufReader, Read, Write};
1756    use std::path::{Path, PathBuf};
1757    use std::process::{Child, Command, Stdio};
1758    use tempfile::tempdir;
1759
1760    const CHILD_MODE: &str = "HARN_SAFE_TEXT_PATCH_TEST_CHILD";
1761    const CHILD_PATH: &str = "HARN_SAFE_TEXT_PATCH_TEST_PATH";
1762    const CHILD_LOCK_ROOT: &str = "HARN_SAFE_TEXT_PATCH_TEST_LOCK_ROOT";
1763    const CHILD_EXPECTED_HASH: &str = "HARN_SAFE_TEXT_PATCH_TEST_EXPECTED_HASH";
1764
1765    #[test]
1766    fn scoped_lock_root_is_nested_and_restored() {
1767        let dir = tempdir().expect("tempdir");
1768        let outer = dir.path().join("outer-locks");
1769        let inner = dir.path().join("inner-locks");
1770
1771        let _outer = scope_safe_text_patch_lock_root(&outer);
1772        assert_eq!(safe_text_patch_lock_root(), outer);
1773        {
1774            let _inner = scope_safe_text_patch_lock_root(&inner);
1775            assert_eq!(safe_text_patch_lock_root(), inner);
1776        }
1777        assert_eq!(safe_text_patch_lock_root(), outer);
1778    }
1779
1780    #[test]
1781    fn default_lock_root_follows_runtime_state_root() {
1782        let runtime_root = harn_vm::stdlib::process::runtime_root_base();
1783        assert_eq!(
1784            safe_text_patch_lock_root(),
1785            harn_vm::runtime_paths::state_root(&runtime_root).join("fs-cas-locks")
1786        );
1787    }
1788
1789    #[cfg(any(target_os = "macos", target_os = "windows"))]
1790    #[test]
1791    fn absent_target_case_aliases_share_one_lock() {
1792        let dir = tempdir().expect("tempdir");
1793        let lock_root = dir.path().join("locks");
1794
1795        assert_lock_aliases_contend(
1796            &dir.path().join("Missing.txt"),
1797            &dir.path().join("missing.txt"),
1798            &lock_root,
1799        );
1800    }
1801
1802    #[cfg(unix)]
1803    #[test]
1804    fn existing_target_symlink_aliases_share_one_lock() {
1805        let dir = tempdir().expect("tempdir");
1806        let target = dir.path().join("target.txt");
1807        let alias = dir.path().join("alias.txt");
1808        let lock_root = dir.path().join("locks");
1809        std::fs::write(&target, b"original\n").expect("write target");
1810        std::os::unix::fs::symlink(&target, &alias).expect("create symlink alias");
1811
1812        assert_lock_aliases_contend(&target, &alias, &lock_root);
1813    }
1814
1815    #[test]
1816    fn safe_text_patch_process_child() {
1817        let Ok(mode) = std::env::var(CHILD_MODE) else {
1818            return;
1819        };
1820        let path = PathBuf::from(std::env::var_os(CHILD_PATH).expect("child path"));
1821        let lock_root = PathBuf::from(std::env::var_os(CHILD_LOCK_ROOT).expect("lock root"));
1822        let expected_hash = std::env::var(CHILD_EXPECTED_HASH).expect("expected hash");
1823        match mode.as_str() {
1824            "holder" => run_lock_holder(&path, &lock_root, &expected_hash),
1825            "contender" => run_lock_contender(&path, &lock_root, &expected_hash),
1826            other => panic!("unknown child mode {other}"),
1827        }
1828    }
1829
1830    #[test]
1831    fn immediate_safe_text_patch_is_compare_and_swap_across_processes() {
1832        let dir = tempdir().expect("tempdir");
1833        let path = dir.path().join("shared.txt");
1834        let lock_root = dir.path().join("locks");
1835        std::fs::write(&path, b"original\n").expect("write preimage");
1836        let expected_hash = hash_label(b"original\n");
1837
1838        let (mut holder, mut holder_stdout) =
1839            spawn_child("holder", &path, &lock_root, &expected_hash);
1840        read_until(&mut holder_stdout, "LOCKED");
1841        let (mut contender, mut contender_stdout) =
1842            spawn_child("contender", &path, &lock_root, &expected_hash);
1843        read_until(&mut contender_stdout, "LOCK_CONTENDED");
1844
1845        holder
1846            .stdin
1847            .take()
1848            .expect("holder stdin")
1849            .write_all(b"commit\n")
1850            .expect("release holder");
1851        read_until(&mut holder_stdout, "RESULT:applied");
1852        assert!(holder.wait().expect("wait holder").success());
1853        read_until(&mut contender_stdout, "RESULT:stale_base");
1854        assert!(contender.wait().expect("wait contender").success());
1855        assert_eq!(std::fs::read(&path).expect("read postimage"), b"holder\n");
1856    }
1857
1858    #[test]
1859    fn immediate_safe_text_patch_lock_is_exclusive_within_one_process() {
1860        let dir = tempdir().expect("tempdir");
1861        let path = dir.path().join("shared.txt");
1862        let lock_root = dir.path().join("locks");
1863        std::fs::write(&path, b"original\n").expect("write preimage");
1864
1865        let holder = acquire_safe_text_patch_lock(&path, &lock_root).expect("holder lock");
1866        let contender = open_safe_text_patch_lock(&path, &lock_root).expect("contender lock file");
1867        let error = contender
1868            .try_lock_exclusive()
1869            .expect_err("a second same-process open must observe contention");
1870        assert_eq!(
1871            error.raw_os_error(),
1872            fs2::lock_contended_error().raw_os_error()
1873        );
1874
1875        drop(holder);
1876        contender
1877            .try_lock_exclusive()
1878            .expect("dropping the owner must release the advisory lock");
1879        FileExt::unlock(&contender).expect("release contender lock");
1880    }
1881
1882    fn assert_lock_aliases_contend(first: &Path, second: &Path, lock_root: &Path) {
1883        let holder = acquire_safe_text_patch_lock(first, lock_root).expect("holder lock");
1884        let contender = open_safe_text_patch_lock(second, lock_root).expect("alias lock file");
1885        let error = contender
1886            .try_lock_exclusive()
1887            .expect_err("path aliases must contend on one lock");
1888        assert_eq!(
1889            error.raw_os_error(),
1890            fs2::lock_contended_error().raw_os_error()
1891        );
1892
1893        drop(holder);
1894        contender
1895            .try_lock_exclusive()
1896            .expect("dropping the owner must release the alias lock");
1897        FileExt::unlock(&contender).expect("release alias lock");
1898    }
1899
1900    fn run_lock_holder(path: &Path, lock_root: &Path, expected_hash: &str) {
1901        let _lock = acquire_safe_text_patch_lock(path, lock_root).expect("acquire holder lock");
1902        println!("LOCKED");
1903        std::io::stdout().flush().expect("flush locked signal");
1904        let mut release = [0_u8; 1];
1905        std::io::stdin()
1906            .read_exact(&mut release)
1907            .expect("read release signal");
1908        let outcome = safe_text_patch_disk_locked(
1909            path,
1910            b"holder\n",
1911            Some(expected_hash),
1912            false,
1913            true,
1914            hash_label(b"holder\n"),
1915        )
1916        .expect("holder patch");
1917        println!("RESULT:{}", outcome.result.as_str());
1918    }
1919
1920    fn run_lock_contender(path: &Path, lock_root: &Path, expected_hash: &str) {
1921        let probe = open_safe_text_patch_lock(path, lock_root).expect("open contender probe");
1922        let error = probe
1923            .try_lock_exclusive()
1924            .expect_err("holder process must own the target lock");
1925        assert_eq!(
1926            error.raw_os_error(),
1927            fs2::lock_contended_error().raw_os_error()
1928        );
1929        println!("LOCK_CONTENDED");
1930        std::io::stdout().flush().expect("flush contention signal");
1931        drop(probe);
1932        let outcome = safe_text_patch_disk_with_lock_root(
1933            path,
1934            b"contender\n",
1935            Some(expected_hash),
1936            false,
1937            true,
1938            hash_label(b"contender\n"),
1939            lock_root,
1940        )
1941        .expect("contender patch");
1942        assert_eq!(outcome.result, SafeTextPatchResult::StaleBase);
1943        println!("RESULT:{}", outcome.result.as_str());
1944    }
1945
1946    fn spawn_child(
1947        mode: &str,
1948        path: &Path,
1949        lock_root: &Path,
1950        expected_hash: &str,
1951    ) -> (Child, BufReader<std::process::ChildStdout>) {
1952        let mut child = Command::new(std::env::current_exe().expect("current test executable"))
1953            .arg("safe_text_patch_process_child")
1954            .arg("--nocapture")
1955            .env(CHILD_MODE, mode)
1956            .env(CHILD_PATH, path)
1957            .env(CHILD_LOCK_ROOT, lock_root)
1958            .env(CHILD_EXPECTED_HASH, expected_hash)
1959            .stdin(Stdio::piped())
1960            .stdout(Stdio::piped())
1961            .stderr(Stdio::inherit())
1962            .spawn()
1963            .expect("spawn child test process");
1964        let stdout = BufReader::new(child.stdout.take().expect("child stdout"));
1965        (child, stdout)
1966    }
1967
1968    fn read_until(reader: &mut impl BufRead, marker: &str) {
1969        let mut line = String::new();
1970        loop {
1971            line.clear();
1972            assert!(reader.read_line(&mut line).expect("read child output") > 0);
1973            if line.trim_end() == marker {
1974                return;
1975            }
1976        }
1977    }
1978}
1979
1980#[cfg(test)]
1981mod sanitize_tests {
1982    use super::{sanitize_component, session_dir, STATE_REL};
1983    use std::path::{Component, Path};
1984
1985    #[test]
1986    fn dotted_session_ids_are_never_traversal_tokens() {
1987        // `.`, `..`, `...` must not survive verbatim — otherwise
1988        // `session_dir`'s `dir.push(..)` escapes the staged-state root.
1989        for evil in ["..", ".", "...", ""] {
1990            let safe = sanitize_component(evil);
1991            assert_ne!(safe, evil, "`{evil}` passed through unsanitized");
1992            assert!(
1993                !safe.bytes().all(|b| b == b'.'),
1994                "`{evil}` -> `{safe}` is still all dots"
1995            );
1996            // The result is a single normal component (no ParentDir/CurDir).
1997            let comps: Vec<_> = Path::new(&safe).components().collect();
1998            assert!(
1999                comps.iter().all(|c| matches!(c, Component::Normal(_))),
2000                "`{safe}` contains a traversal component"
2001            );
2002        }
2003    }
2004
2005    #[test]
2006    fn ordinary_session_ids_pass_through() {
2007        assert_eq!(sanitize_component("abc-123_v2.0"), "abc-123_v2.0");
2008    }
2009
2010    #[test]
2011    fn session_dir_stays_under_staged_root() {
2012        let dir = session_dir(Path::new("/workspace"), "..");
2013        // No path component resolves above the staged dir.
2014        assert!(
2015            !dir.components().any(|c| matches!(c, Component::ParentDir)),
2016            "session_dir({dir:?}) escapes via `..`"
2017        );
2018        let mut staged = std::path::PathBuf::from("/workspace");
2019        staged.extend(STATE_REL);
2020        assert!(dir.starts_with(&staged), "{dir:?} not under {staged:?}");
2021    }
2022}