1use 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#[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum FsMode {
89 Immediate,
91 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 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#[derive(Clone, Debug)]
181pub struct StagedStatus {
182 pub pending_writes: Vec<PendingWrite>,
184 pub total_bytes_pending: u64,
186 pub oldest_pending_age_ms: i64,
188}
189
190#[derive(Clone, Debug)]
191pub struct PendingWrite {
193 pub path: String,
195 pub kind: &'static str,
197 pub bytes_added: u64,
199 pub bytes_removed: u64,
201}
202
203#[derive(Clone, Debug)]
205pub struct SetModeResult {
206 pub previous_mode: FsMode,
208}
209
210#[derive(Clone, Debug)]
212pub struct CommitResult {
213 pub committed_paths: Vec<String>,
215 pub failed_paths_with_reasons: Vec<(String, String)>,
217}
218
219#[derive(Clone, Debug)]
221pub struct DiscardResult {
222 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
232fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
236 sessions()
237 .lock()
238 .expect("hostlib fs session mutex poisoned")
239}
240
241pub 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
268pub 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
277pub 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
296pub 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
306pub(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
321pub 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 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
369pub 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
390pub 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#[derive(Clone, Debug)]
577pub struct SafeTextPatchOutcome {
578 pub result: SafeTextPatchResult,
580 pub current_hash: String,
582 pub after_hash: String,
584 pub created: bool,
586 pub bytes_written: usize,
588}
589
590#[derive(Clone, Copy, Debug, Eq, PartialEq)]
592pub enum SafeTextPatchResult {
593 Applied,
596 StaleBase,
599 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
614fn hash_label(bytes: &[u8]) -> String {
618 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
619}
620
621pub 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#[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
792fn 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
913fn 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 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()), "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 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 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 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 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}