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