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