Skip to main content

harn_hostlib/fs/
safe_text_patch_lock.rs

1//! Cross-process lock coordination for immediate safe-text patches.
2
3use std::cell::RefCell;
4use std::fs::{self as stdfs, File, OpenOptions};
5use std::path::{Path, PathBuf};
6
7use fs2::FileExt;
8use sha2::{Digest, Sha256};
9
10use crate::error::HostlibError;
11
12use super::{normalize_logical, SAFE_TEXT_PATCH_BUILTIN};
13
14thread_local! {
15    static EXECUTION_LOCK_ROOT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
16}
17
18/// Restores the prior execution-local safe-text-patch lock root on drop.
19#[derive(Debug)]
20#[must_use = "retain this guard for the execution that owns the lock root"]
21pub struct ScopedSafeTextPatchLockRoot {
22    previous: Option<PathBuf>,
23}
24
25/// Route immediate safe-text-patch locks through a caller-owned execution root.
26///
27/// The override is thread-local so concurrent VM executions can keep their
28/// durable state separate without changing the process-wide production lock
29/// namespace. Callers should pass the `fs-cas-locks` directory under their
30/// execution state root and retain the returned guard for the execution.
31pub fn scope_safe_text_patch_lock_root(root: impl AsRef<Path>) -> ScopedSafeTextPatchLockRoot {
32    let root = root.as_ref().to_path_buf();
33    let previous = EXECUTION_LOCK_ROOT.with(|slot| slot.replace(Some(root)));
34    ScopedSafeTextPatchLockRoot { previous }
35}
36
37impl Drop for ScopedSafeTextPatchLockRoot {
38    fn drop(&mut self) {
39        EXECUTION_LOCK_ROOT.with(|slot| {
40            slot.replace(self.previous.take());
41        });
42    }
43}
44
45pub(super) fn safe_text_patch_lock_root() -> PathBuf {
46    if let Some(root) = EXECUTION_LOCK_ROOT.with(|slot| slot.borrow().clone()) {
47        return root;
48    }
49    let runtime_root = harn_vm::stdlib::process::runtime_root_base();
50    harn_vm::runtime_paths::state_root(&runtime_root).join("fs-cas-locks")
51}
52
53pub(super) fn acquire_safe_text_patch_lock(
54    path: &Path,
55    lock_root: &Path,
56) -> Result<SafeTextPatchLock, HostlibError> {
57    let file = open_safe_text_patch_lock(path, lock_root)?;
58    file.lock_exclusive()
59        .map_err(|error| HostlibError::Backend {
60            builtin: SAFE_TEXT_PATCH_BUILTIN,
61            message: format!("acquire CAS lock for `{}`: {error}", path.display()),
62        })?;
63    Ok(SafeTextPatchLock { file })
64}
65
66pub(super) fn open_safe_text_patch_lock(
67    path: &Path,
68    lock_root: &Path,
69) -> Result<File, HostlibError> {
70    stdfs::create_dir_all(lock_root).map_err(|error| HostlibError::Backend {
71        builtin: SAFE_TEXT_PATCH_BUILTIN,
72        message: format!(
73            "create CAS lock directory `{}`: {error}",
74            lock_root.display()
75        ),
76    })?;
77    let identity = canonical_lock_identity(path);
78    let lock_name = format!(
79        "{}.lock",
80        hex::encode(Sha256::digest(lock_identity_bytes(&identity)))
81    );
82    let lock_path = lock_root.join(lock_name);
83    OpenOptions::new()
84        .create(true)
85        .truncate(false)
86        .read(true)
87        .write(true)
88        .open(&lock_path)
89        .map_err(|error| HostlibError::Backend {
90            builtin: SAFE_TEXT_PATCH_BUILTIN,
91            message: format!("open CAS lock `{}`: {error}", lock_path.display()),
92        })
93}
94
95fn lock_identity_bytes(identity: &Path) -> Vec<u8> {
96    #[cfg(any(target_os = "macos", target_os = "windows"))]
97    {
98        // These platforms commonly use case-insensitive filesystems. Folding
99        // only the private lock key safely over-serializes case-sensitive
100        // volumes while preventing two spellings of a missing target from
101        // acquiring different locks before the first create.
102        identity.to_string_lossy().to_lowercase().into_bytes()
103    }
104    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
105    {
106        identity.as_os_str().as_encoded_bytes().to_vec()
107    }
108}
109
110fn canonical_lock_identity(path: &Path) -> PathBuf {
111    if let Ok(canonical) = stdfs::canonicalize(path) {
112        return canonical;
113    }
114    let absolute = normalize_logical(path);
115    let mut ancestor = absolute.as_path();
116    let mut suffix = Vec::new();
117    while let Some(name) = ancestor.file_name() {
118        suffix.push(name.to_os_string());
119        let Some(parent) = ancestor.parent() else {
120            break;
121        };
122        if let Ok(canonical_parent) = stdfs::canonicalize(parent) {
123            let mut identity = canonical_parent;
124            for component in suffix.iter().rev() {
125                identity.push(component);
126            }
127            return identity;
128        }
129        ancestor = parent;
130    }
131    absolute
132}
133
134pub(super) struct SafeTextPatchLock {
135    file: File,
136}
137
138impl Drop for SafeTextPatchLock {
139    fn drop(&mut self) {
140        // Keep the lock file itself: unlinking it can split waiters across two
141        // inodes. The OS releases ownership automatically when a process exits.
142        let _ = FileExt::unlock(&self.file);
143    }
144}