Skip to main content

harn_vm/
conditional_replace.rs

1//! Cross-process compare-and-replace for complete file payloads.
2
3use std::cell::RefCell;
4use std::fs::{self, File, OpenOptions};
5use std::io;
6use std::path::{Path, PathBuf};
7
8use sha2::{Digest, Sha256};
9
10use crate::atomic_io::{
11    atomic_write_with_durability_unlocked, AtomicWriteDurability, AtomicWriteReceipt,
12};
13
14thread_local! {
15    static EXECUTION_LOCK_ROOT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
16}
17
18/// Restores the prior execution-local replacement lock root on drop.
19#[derive(Debug)]
20#[must_use = "retain this guard for the execution that owns the lock root"]
21pub struct ScopedConditionalReplaceLockRoot {
22    previous: Option<PathBuf>,
23}
24
25/// Route compare-and-replace locks through a caller-owned root on this thread.
26/// Worker threads must install their own guard; the override is intended for
27/// isolated embedders and tests, not as ambient workflow state.
28pub fn scope_conditional_replace_lock_root(
29    root: impl AsRef<Path>,
30) -> ScopedConditionalReplaceLockRoot {
31    let previous = EXECUTION_LOCK_ROOT.with(|slot| slot.replace(Some(root.as_ref().to_path_buf())));
32    ScopedConditionalReplaceLockRoot { previous }
33}
34
35impl Drop for ScopedConditionalReplaceLockRoot {
36    fn drop(&mut self) {
37        EXECUTION_LOCK_ROOT.with(|slot| {
38            slot.replace(self.previous.take());
39        });
40    }
41}
42
43/// Preconditions and durability for one replacement.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct ConditionalReplaceOptions {
46    /// Digest observed by the caller, including the `sha256:` prefix.
47    pub expected_sha256: Option<String>,
48    /// Allow a missing destination to be created.
49    pub create: bool,
50    /// Allow an existing destination to change.
51    pub overwrite: bool,
52    /// Create a missing parent chain.
53    pub create_parents: bool,
54    /// Requested namespace or storage-flush durability.
55    pub durability: AtomicWriteDurability,
56}
57
58impl Default for ConditionalReplaceOptions {
59    fn default() -> Self {
60        Self {
61            expected_sha256: None,
62            create: true,
63            overwrite: true,
64            create_parents: true,
65            durability: AtomicWriteDurability::Flush,
66        }
67    }
68}
69
70/// Closed successful outcome for one replacement attempt.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum ConditionalReplaceStatus {
73    Created,
74    Replaced,
75    NoOp,
76    Stale,
77}
78
79impl ConditionalReplaceStatus {
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Created => "created",
83            Self::Replaced => "replaced",
84            Self::NoOp => "no_op",
85            Self::Stale => "stale",
86        }
87    }
88}
89
90/// Receipt for one compare-and-replace operation.
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct ConditionalReplaceReceipt {
93    /// Closed operation outcome.
94    pub status: ConditionalReplaceStatus,
95    /// Whether the destination existed before this attempt.
96    pub before_exists: bool,
97    /// Digest observed while the replacement lock was held.
98    pub before_sha256: String,
99    /// Digest of the requested complete payload.
100    pub after_sha256: String,
101    /// Caller-supplied lease, when present.
102    pub expected_sha256: Option<String>,
103    /// Payload bytes written; zero for `NoOp` and `Stale`.
104    pub bytes_written: usize,
105    /// Whether the complete payload was flushed to the operating system.
106    pub file_synced: bool,
107    /// Whether persistence of the namespace replacement was confirmed.
108    pub namespace_synced: bool,
109}
110
111/// Replace a complete file payload under a canonical-path cross-process lock.
112pub fn conditional_replace(
113    path: &Path,
114    contents: &[u8],
115    options: &ConditionalReplaceOptions,
116) -> io::Result<ConditionalReplaceReceipt> {
117    conditional_replace_with_hook(path, contents, options, || {})
118}
119
120/// Variant used by hosts that must snapshot the pre-image immediately before
121/// mutation while the replacement lock is still held.
122pub fn conditional_replace_with_hook<F>(
123    path: &Path,
124    contents: &[u8],
125    options: &ConditionalReplaceOptions,
126    before_write: F,
127) -> io::Result<ConditionalReplaceReceipt>
128where
129    F: FnOnce(),
130{
131    conditional_replace_with_io(
132        path,
133        contents,
134        options,
135        |candidate| {
136            reject_symlink_destination(candidate)?;
137            fs::read(candidate)
138        },
139        |candidate, bytes, durability, create_parents| {
140            require_parent(candidate, create_parents)?;
141            // The compare-and-replace lock already covers the complete read,
142            // lease check, and write boundary.
143            atomic_write_with_durability_unlocked(candidate, bytes, durability)
144        },
145        before_write,
146    )
147}
148
149pub(crate) fn conditional_replace_with_io<R, W, F>(
150    path: &Path,
151    contents: &[u8],
152    options: &ConditionalReplaceOptions,
153    read: R,
154    write: W,
155    before_write: F,
156) -> io::Result<ConditionalReplaceReceipt>
157where
158    R: FnOnce(&Path) -> io::Result<Vec<u8>>,
159    W: FnOnce(&Path, &[u8], AtomicWriteDurability, bool) -> io::Result<AtomicWriteReceipt>,
160    F: FnOnce(),
161{
162    let _lock = acquire_lock(path)?;
163    let (before, before_exists) = match read(path) {
164        Ok(bytes) => (bytes, true),
165        Err(error) if error.kind() == io::ErrorKind::NotFound => (Vec::new(), false),
166        Err(error) => return Err(error),
167    };
168    let before_sha256 = sha256_label(&before);
169    let after_sha256 = sha256_label(contents);
170
171    if options
172        .expected_sha256
173        .as_deref()
174        .is_some_and(|expected| expected != before_sha256)
175    {
176        return Ok(ConditionalReplaceReceipt {
177            status: ConditionalReplaceStatus::Stale,
178            before_exists,
179            before_sha256,
180            after_sha256,
181            expected_sha256: options.expected_sha256.clone(),
182            bytes_written: 0,
183            file_synced: false,
184            namespace_synced: false,
185        });
186    }
187
188    if before_exists && before == contents {
189        return Ok(ConditionalReplaceReceipt {
190            status: ConditionalReplaceStatus::NoOp,
191            before_exists: true,
192            before_sha256,
193            after_sha256,
194            expected_sha256: options.expected_sha256.clone(),
195            bytes_written: 0,
196            file_synced: false,
197            namespace_synced: false,
198        });
199    }
200    if before_exists && !options.overwrite {
201        return Err(io::Error::new(
202            io::ErrorKind::AlreadyExists,
203            format!("'{}' exists and overwrite=false", path.display()),
204        ));
205    }
206    if !before_exists && !options.create {
207        return Err(io::Error::new(
208            io::ErrorKind::NotFound,
209            format!("'{}' does not exist and create=false", path.display()),
210        ));
211    }
212    before_write();
213    let durability = write(path, contents, options.durability, options.create_parents)?;
214    Ok(ConditionalReplaceReceipt {
215        status: if before_exists {
216            ConditionalReplaceStatus::Replaced
217        } else {
218            ConditionalReplaceStatus::Created
219        },
220        before_exists,
221        before_sha256,
222        after_sha256,
223        expected_sha256: options.expected_sha256.clone(),
224        bytes_written: contents.len(),
225        file_synced: durability.file_synced,
226        namespace_synced: durability.namespace_synced,
227    })
228}
229
230fn reject_symlink_destination(path: &Path) -> io::Result<()> {
231    match fs::symlink_metadata(path) {
232        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
233            io::ErrorKind::InvalidInput,
234            format!(
235                "refusing to replace symlink destination '{}'",
236                path.display()
237            ),
238        )),
239        Ok(_) => Ok(()),
240        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
241        Err(error) => Err(error),
242    }
243}
244
245pub(crate) fn require_parent(path: &Path, create_parents: bool) -> io::Result<()> {
246    if create_parents {
247        return Ok(());
248    }
249    if let Some(parent) = path.parent() {
250        if !parent.as_os_str().is_empty() && !parent.is_dir() {
251            return Err(io::Error::new(
252                io::ErrorKind::NotFound,
253                format!(
254                    "parent directory for '{}' does not exist (pass create_parents=true to create it)",
255                    path.display()
256                ),
257            ));
258        }
259    }
260    Ok(())
261}
262
263fn sha256_label(bytes: &[u8]) -> String {
264    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
265}
266
267fn lock_root() -> PathBuf {
268    if let Some(root) = EXECUTION_LOCK_ROOT.with(|slot| slot.borrow().clone()) {
269        return root;
270    }
271    let runtime_root = crate::stdlib::process::runtime_root_base();
272    crate::runtime_paths::state_root(&runtime_root).join("fs-cas-locks")
273}
274
275pub(crate) fn acquire_lock(path: &Path) -> io::Result<ConditionalReplaceLock> {
276    let root = lock_root();
277    fs::create_dir_all(&root)?;
278    let identity = canonical_lock_identity(path);
279    let name = format!(
280        "{}.lock",
281        hex::encode(Sha256::digest(lock_identity_bytes(&identity)))
282    );
283    let file = OpenOptions::new()
284        .create(true)
285        .truncate(false)
286        .read(true)
287        .write(true)
288        .open(root.join(name))?;
289    file.lock()?;
290    Ok(ConditionalReplaceLock { file })
291}
292
293fn lock_identity_bytes(identity: &Path) -> Vec<u8> {
294    #[cfg(any(target_os = "macos", target_os = "windows"))]
295    {
296        identity.to_string_lossy().to_lowercase().into_bytes()
297    }
298    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
299    {
300        identity.as_os_str().as_encoded_bytes().to_vec()
301    }
302}
303
304fn canonical_lock_identity(path: &Path) -> PathBuf {
305    if let Ok(canonical) = fs::canonicalize(path) {
306        return canonical;
307    }
308    let absolute = if path.is_absolute() {
309        path.to_path_buf()
310    } else {
311        std::env::current_dir()
312            .unwrap_or_else(|_| PathBuf::from("."))
313            .join(path)
314    };
315    let mut ancestor = absolute.as_path();
316    let mut suffix = Vec::new();
317    while let Some(name) = ancestor.file_name() {
318        suffix.push(name.to_os_string());
319        let Some(parent) = ancestor.parent() else {
320            break;
321        };
322        if let Ok(canonical_parent) = fs::canonicalize(parent) {
323            let mut identity = canonical_parent;
324            for component in suffix.iter().rev() {
325                identity.push(component);
326            }
327            return identity;
328        }
329        ancestor = parent;
330    }
331    absolute
332}
333
334pub(crate) struct ConditionalReplaceLock {
335    file: File,
336}
337
338impl Drop for ConditionalReplaceLock {
339    fn drop(&mut self) {
340        let _ = self.file.unlock();
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::sync::{Arc, Barrier};
348
349    #[test]
350    fn stale_digest_never_writes() {
351        let dir = tempfile::tempdir().unwrap();
352        let _locks = scope_conditional_replace_lock_root(dir.path().join("locks"));
353        let path = dir.path().join("state.json");
354        fs::write(&path, b"current").unwrap();
355        let options = ConditionalReplaceOptions {
356            expected_sha256: Some(sha256_label(b"older")),
357            ..ConditionalReplaceOptions::default()
358        };
359        let receipt = conditional_replace(&path, b"new", &options).unwrap();
360        assert_eq!(receipt.status, ConditionalReplaceStatus::Stale);
361        assert_eq!(fs::read(&path).unwrap(), b"current");
362    }
363
364    #[test]
365    fn create_replace_and_no_op_are_distinct() {
366        let dir = tempfile::tempdir().unwrap();
367        let _locks = scope_conditional_replace_lock_root(dir.path().join("locks"));
368        let path = dir.path().join("state.json");
369        let options = ConditionalReplaceOptions::default();
370
371        let created = conditional_replace(&path, b"one", &options).unwrap();
372        assert_eq!(created.status, ConditionalReplaceStatus::Created);
373        let no_op = conditional_replace(&path, b"one", &options).unwrap();
374        assert_eq!(no_op.status, ConditionalReplaceStatus::NoOp);
375        let replaced = conditional_replace(&path, b"two", &options).unwrap();
376        assert_eq!(replaced.status, ConditionalReplaceStatus::Replaced);
377    }
378
379    #[test]
380    fn one_concurrent_writer_wins_an_observed_digest() {
381        let dir = tempfile::tempdir().unwrap();
382        let path = Arc::new(dir.path().join("state.json"));
383        let lock_root = Arc::new(dir.path().join("locks"));
384        fs::write(path.as_ref(), b"original").unwrap();
385        let expected = sha256_label(b"original");
386        let barrier = Arc::new(Barrier::new(17));
387        let mut workers = Vec::new();
388        for index in 0..16 {
389            let path = Arc::clone(&path);
390            let barrier = Arc::clone(&barrier);
391            let expected = expected.clone();
392            let lock_root = Arc::clone(&lock_root);
393            workers.push(std::thread::spawn(move || {
394                let _locks = scope_conditional_replace_lock_root(lock_root.as_ref());
395                let payload = format!("writer-{index}");
396                let options = ConditionalReplaceOptions {
397                    expected_sha256: Some(expected),
398                    ..ConditionalReplaceOptions::default()
399                };
400                barrier.wait();
401                conditional_replace(path.as_ref(), payload.as_bytes(), &options).unwrap()
402            }));
403        }
404        barrier.wait();
405        let receipts: Vec<_> = workers
406            .into_iter()
407            .map(|worker| worker.join().unwrap())
408            .collect();
409        assert_eq!(
410            receipts
411                .iter()
412                .filter(|receipt| receipt.status == ConditionalReplaceStatus::Replaced)
413                .count(),
414            1
415        );
416        assert_eq!(
417            receipts
418                .iter()
419                .filter(|receipt| receipt.status == ConditionalReplaceStatus::Stale)
420                .count(),
421            15
422        );
423    }
424
425    #[test]
426    fn canonical_path_aliases_share_a_lock_identity() {
427        let dir = tempfile::tempdir().unwrap();
428        fs::create_dir(dir.path().join("sub")).unwrap();
429        let path = dir.path().join("state.json");
430        let alias = dir.path().join("sub/../state.json");
431        fs::write(&path, b"original").unwrap();
432        assert_eq!(
433            canonical_lock_identity(&path),
434            canonical_lock_identity(&alias)
435        );
436    }
437
438    #[cfg(unix)]
439    #[test]
440    fn symlink_destinations_fail_closed() {
441        let dir = tempfile::tempdir().unwrap();
442        let _locks = scope_conditional_replace_lock_root(dir.path().join("locks"));
443        let target = dir.path().join("target.txt");
444        let alias = dir.path().join("alias.txt");
445        fs::write(&target, b"original").unwrap();
446        std::os::unix::fs::symlink(&target, &alias).unwrap();
447
448        let error =
449            conditional_replace(&alias, b"new", &ConditionalReplaceOptions::default()).unwrap_err();
450        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
451        assert_eq!(fs::read(&target).unwrap(), b"original");
452        assert!(fs::symlink_metadata(alias)
453            .unwrap()
454            .file_type()
455            .is_symlink());
456    }
457
458    #[test]
459    fn write_failure_preserves_the_preimage() {
460        let dir = tempfile::tempdir().unwrap();
461        let _locks = scope_conditional_replace_lock_root(dir.path().join("locks"));
462        let path = dir.path().join("state.json");
463        fs::write(&path, b"old").unwrap();
464        let hook_calls = std::cell::Cell::new(0);
465        let error = conditional_replace_with_io(
466            &path,
467            b"new",
468            &ConditionalReplaceOptions::default(),
469            |candidate| fs::read(candidate),
470            |_, _, _, _| Err(io::Error::other("injected write failure")),
471            || hook_calls.set(hook_calls.get() + 1),
472        )
473        .unwrap_err();
474        assert_eq!(error.to_string(), "injected write failure");
475        assert_eq!(hook_calls.get(), 1);
476        assert_eq!(fs::read(path).unwrap(), b"old");
477    }
478
479    #[test]
480    fn create_and_overwrite_policies_fail_closed() {
481        let dir = tempfile::tempdir().unwrap();
482        let _locks = scope_conditional_replace_lock_root(dir.path().join("locks"));
483        let path = dir.path().join("state.json");
484        let no_create = ConditionalReplaceOptions {
485            create: false,
486            ..ConditionalReplaceOptions::default()
487        };
488        assert_eq!(
489            conditional_replace(&path, b"new", &no_create)
490                .unwrap_err()
491                .kind(),
492            io::ErrorKind::NotFound
493        );
494        fs::write(&path, b"old").unwrap();
495        let no_overwrite = ConditionalReplaceOptions {
496            overwrite: false,
497            ..ConditionalReplaceOptions::default()
498        };
499        assert_eq!(
500            conditional_replace(&path, b"new", &no_overwrite)
501                .unwrap_err()
502                .kind(),
503            io::ErrorKind::AlreadyExists
504        );
505        assert_eq!(fs::read(path).unwrap(), b"old");
506    }
507}