1use 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#[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
25pub 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#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct ConditionalReplaceOptions {
46 pub expected_sha256: Option<String>,
48 pub create: bool,
50 pub overwrite: bool,
52 pub create_parents: bool,
54 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct ConditionalReplaceReceipt {
93 pub status: ConditionalReplaceStatus,
95 pub before_exists: bool,
97 pub before_sha256: String,
99 pub after_sha256: String,
101 pub expected_sha256: Option<String>,
103 pub bytes_written: usize,
105 pub file_synced: bool,
107 pub namespace_synced: bool,
109}
110
111pub 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
120pub 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 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}