1use 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#[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum FsMode {
83 Immediate,
85 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 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#[derive(Clone, Debug)]
175pub struct StagedStatus {
176 pub pending_writes: Vec<PendingWrite>,
178 pub total_bytes_pending: u64,
180 pub oldest_pending_age_ms: i64,
182}
183
184#[derive(Clone, Debug)]
185pub struct PendingWrite {
187 pub path: String,
189 pub kind: &'static str,
191 pub bytes_added: u64,
193 pub bytes_removed: u64,
195}
196
197#[derive(Clone, Debug)]
199pub struct SetModeResult {
200 pub previous_mode: FsMode,
202}
203
204#[derive(Clone, Debug)]
206pub struct CommitResult {
207 pub committed_paths: Vec<String>,
209 pub failed_paths_with_reasons: Vec<(String, String)>,
211}
212
213#[derive(Clone, Debug)]
215pub struct DiscardResult {
216 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
226fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
230 sessions()
231 .lock()
232 .expect("hostlib fs session mutex poisoned")
233}
234
235pub 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
262pub 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
271pub 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
290pub 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
300pub(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
315pub 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 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
363pub 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
384pub 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#[derive(Clone, Debug)]
571pub struct SafeTextPatchOutcome {
572 pub result: SafeTextPatchResult,
574 pub current_hash: String,
576 pub after_hash: String,
578 pub created: bool,
580 pub bytes_written: usize,
582}
583
584#[derive(Clone, Copy, Debug, Eq, PartialEq)]
586pub enum SafeTextPatchResult {
587 Applied,
590 StaleBase,
593 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
608fn hash_label(bytes: &[u8]) -> String {
612 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
613}
614
615pub 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#[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
786fn 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 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 let _ = FileExt::unlock(&self.file);
1003 }
1004}
1005
1006fn 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 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()), "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 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 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 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 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}