1use std::{
3 fs::{self, File, OpenOptions},
4 io::{self, Write},
5 path::{Path, PathBuf},
6 sync::{
7 Arc, Mutex, OnceLock,
8 atomic::{AtomicU64, Ordering},
9 },
10 time::{SystemTime, UNIX_EPOCH},
11};
12
13#[derive(Default)]
14struct CloneDurabilityStats {
15 barriers: AtomicU64,
16 skipped: AtomicU64,
17}
18
19#[derive(Clone)]
20struct CloneDurabilityEntry {
21 root: PathBuf,
22 stats: Arc<CloneDurabilityStats>,
23}
24
25fn clone_durability_entries() -> &'static Mutex<Vec<CloneDurabilityEntry>> {
26 static ENTRIES: OnceLock<Mutex<Vec<CloneDurabilityEntry>>> = OnceLock::new();
27 ENTRIES.get_or_init(|| Mutex::new(Vec::new()))
28}
29
30fn deferred_clone_stats(path: &Path) -> Option<Arc<CloneDurabilityStats>> {
31 clone_durability_entries()
32 .lock()
33 .ok()?
34 .iter()
35 .rev()
36 .find(|entry| path.starts_with(&entry.root))
37 .map(|entry| Arc::clone(&entry.stats))
38}
39
40pub fn clone_write_is_deferred(path: &Path) -> bool {
41 deferred_clone_stats(path).is_some()
42}
43
44pub fn record_deferred_clone_barrier(path: &Path) {
45 if let Some(stats) = deferred_clone_stats(path) {
46 stats.skipped.fetch_add(1, Ordering::Relaxed);
47 }
48}
49
50pub struct CloneDurabilityBatch {
56 root: PathBuf,
57 stats: Arc<CloneDurabilityStats>,
58}
59
60impl CloneDurabilityBatch {
61 pub fn begin(root: impl AsRef<Path>) -> Self {
62 let root = root.as_ref().to_path_buf();
63 let stats = Arc::new(CloneDurabilityStats::default());
64 clone_durability_entries()
65 .lock()
66 .unwrap_or_else(std::sync::PoisonError::into_inner)
67 .push(CloneDurabilityEntry {
68 root: root.clone(),
69 stats: Arc::clone(&stats),
70 });
71 Self { root, stats }
72 }
73
74 pub fn commit(&self) -> io::Result<()> {
77 sync_filesystem(&self.root)?;
78 self.stats.barriers.fetch_add(1, Ordering::Relaxed);
79 Ok(())
80 }
81
82 pub fn barrier_count(&self) -> u64 {
83 self.stats.barriers.load(Ordering::Relaxed)
84 }
85
86 pub fn skipped_barrier_count(&self) -> u64 {
87 self.stats.skipped.load(Ordering::Relaxed)
88 }
89}
90
91impl Drop for CloneDurabilityBatch {
92 fn drop(&mut self) {
93 let mut entries = clone_durability_entries()
94 .lock()
95 .unwrap_or_else(std::sync::PoisonError::into_inner);
96 if let Some(index) = entries
97 .iter()
98 .rposition(|entry| entry.root == self.root && Arc::ptr_eq(&entry.stats, &self.stats))
99 {
100 entries.remove(index);
101 }
102 }
103}
104
105#[derive(Clone, Copy)]
106enum AtomicWriteKind {
107 Normal,
108 Secret,
109}
110
111impl AtomicWriteKind {
112 fn open_tmp(self, tmp: &Path) -> io::Result<File> {
113 let mut options = OpenOptions::new();
114 options.create_new(true).write(true);
115
116 #[cfg(unix)]
117 if matches!(self, Self::Secret) {
118 use std::os::unix::fs::OpenOptionsExt;
119 options.mode(0o600);
120 }
121
122 options.open(tmp)
123 }
124
125 fn enforce_before_write(self, file: &File) -> io::Result<()> {
126 match self {
127 Self::Normal => Ok(()),
128 Self::Secret => enforce_secret_permissions_before_write(file),
129 }
130 }
131}
132
133#[cfg(unix)]
134fn enforce_secret_permissions_before_write(file: &File) -> io::Result<()> {
135 use std::os::unix::fs::PermissionsExt;
136
137 file.set_permissions(fs::Permissions::from_mode(0o600))?;
138 let mode = file.metadata()?.permissions().mode() & 0o777;
139 if mode != 0o600 {
140 return Err(io::Error::new(
141 io::ErrorKind::PermissionDenied,
142 format!("secret temp file permissions are {mode:o}, expected 600"),
143 ));
144 }
145 Ok(())
146}
147
148#[cfg(not(unix))]
149fn enforce_secret_permissions_before_write(_file: &File) -> io::Result<()> {
150 Ok(())
154}
155
156static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
157
158const ENOSPC: i32 = 28;
163
164const ENOTEMPTY_LINUX: i32 = 39;
169const ENOTEMPTY_MACOS: i32 = 66;
170const ENOTEMPTY_WINDOWS: i32 = 145;
171
172const EACCES: i32 = 13;
175
176const ENOENT: i32 = 2;
179
180const EROFS: i32 = 30;
183
184const EXDEV: i32 = 18;
187
188#[cfg(windows)]
191const ERROR_ACCESS_DENIED: i32 = 5;
192
193#[cfg(windows)]
195const ERROR_SHARING_VIOLATION: i32 = 32;
196
197#[cfg(windows)]
198fn windows_rename_is_contended(err: &io::Error) -> bool {
199 matches!(
200 err.raw_os_error(),
201 Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION)
202 )
203}
204
205pub fn is_out_of_space(err: &io::Error) -> bool {
210 if err.raw_os_error() == Some(ENOSPC) {
211 return true;
212 }
213 if err.kind() == io::ErrorKind::StorageFull {
217 return true;
218 }
219 if err.kind() == io::ErrorKind::WriteZero {
225 return true;
226 }
227 false
228}
229
230pub fn is_directory_not_empty(err: &io::Error) -> bool {
238 if err.kind() == io::ErrorKind::DirectoryNotEmpty {
239 return true;
240 }
241 matches!(
242 err.raw_os_error(),
243 Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
244 )
245}
246
247pub fn is_permission_denied(err: &io::Error) -> bool {
253 if err.kind() == io::ErrorKind::PermissionDenied {
254 return true;
255 }
256 err.raw_os_error() == Some(EACCES)
257}
258
259pub fn is_not_found(err: &io::Error) -> bool {
265 if err.kind() == io::ErrorKind::NotFound {
266 return true;
267 }
268 err.raw_os_error() == Some(ENOENT)
269}
270
271pub fn is_read_only_filesystem(err: &io::Error) -> bool {
277 if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
278 return true;
279 }
280 err.raw_os_error() == Some(EROFS)
281}
282
283pub fn is_cross_device_link(err: &io::Error) -> bool {
290 if err.kind() == io::ErrorKind::CrossesDevices {
291 return true;
292 }
293 err.raw_os_error() == Some(EXDEV)
294}
295
296pub fn temp_path(path: &Path) -> PathBuf {
297 let parent = path.parent().unwrap_or_else(|| Path::new("."));
298 let file_name = path
299 .file_name()
300 .and_then(|s| s.to_str())
301 .filter(|s| !s.is_empty())
302 .unwrap_or("heddle-tmp");
303 let unique = SystemTime::now()
304 .duration_since(UNIX_EPOCH)
305 .map(|d| d.as_nanos())
306 .unwrap_or(0);
307 let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
308 let pid = std::process::id();
309 parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
310}
311
312#[cfg(target_os = "linux")]
321fn kick_writeback(file: &File) {
322 use std::os::unix::io::AsRawFd;
323 const SYNC_FILE_RANGE_WRITE: libc::c_uint = 2;
327 unsafe {
328 libc::sync_file_range(file.as_raw_fd(), 0, 0, SYNC_FILE_RANGE_WRITE);
329 }
330}
331
332#[cfg(not(target_os = "linux"))]
333fn kick_writeback(_file: &File) {}
334
335pub fn stage_temp_files_durable(files: &[(PathBuf, Vec<u8>)]) -> io::Result<()> {
355 let mut handles: Vec<File> = Vec::with_capacity(files.len());
356 for (temp_path, bytes) in files {
357 let mut file = File::create(temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
358 file.write_all(bytes)
359 .map_err(|err| enrich_write_error(temp_path, err))?;
360 kick_writeback(&file);
361 handles.push(file);
362 }
363 for (file, (temp_path, _)) in handles.iter().zip(files) {
366 sync_file(file, temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
367 }
368 Ok(())
369}
370
371#[cfg(windows)]
377pub fn sync_directory(_path: &Path) -> io::Result<()> {
378 Ok(())
379}
380
381#[cfg(not(windows))]
382pub fn sync_directory(path: &Path) -> io::Result<()> {
383 if let Some(stats) = deferred_clone_stats(path) {
384 stats.skipped.fetch_add(1, Ordering::Relaxed);
385 return Ok(());
386 }
387 let dir = OpenOptions::new().read(true).open(path)?;
388 dir.sync_all()
389}
390
391pub fn durable_rename(source: &Path, destination: &Path) -> io::Result<()> {
396 #[cfg(windows)]
397 {
398 use std::os::windows::ffi::OsStrExt;
399
400 use windows_sys::Win32::Storage::FileSystem::{
401 MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
402 };
403 fn wide(path: &Path) -> io::Result<Vec<u16>> {
404 let value: Vec<_> = std::path::absolute(path)?
405 .as_os_str()
406 .encode_wide()
407 .collect();
408 if value.contains(&0) {
409 return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"));
410 }
411 Ok(value.into_iter().chain(std::iter::once(0)).collect())
412 }
413 let source = wide(source)?;
414 let destination = wide(destination)?;
415 let mut delay = std::time::Duration::from_millis(15);
418 let mut attempt = 0u32;
419 loop {
420 if unsafe {
422 MoveFileExW(
423 source.as_ptr(),
424 destination.as_ptr(),
425 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
426 )
427 } != 0
428 {
429 return Ok(());
430 }
431 let error = io::Error::last_os_error();
432 if !windows_rename_is_contended(&error) || attempt == 11 {
433 return Err(error);
434 }
435 std::thread::sleep(delay);
436 delay = (delay * 2).min(std::time::Duration::from_millis(100));
437 attempt += 1;
438 }
439 }
440 #[cfg(not(windows))]
441 {
442 fs::rename(source, destination)?;
443 let destination_parent = destination.parent().unwrap_or_else(|| Path::new("."));
444 sync_directory(destination_parent)?;
445 let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
446 if source_parent != destination_parent {
447 sync_directory(source_parent)?;
448 }
449 Ok(())
450 }
451}
452
453pub fn sync_file(file: &File, path: &Path) -> io::Result<()> {
455 if let Some(stats) = deferred_clone_stats(path) {
456 stats.skipped.fetch_add(1, Ordering::Relaxed);
457 return Ok(());
458 }
459 file.sync_all()
460}
461
462pub fn sync_file_data(file: &File, path: &Path) -> io::Result<()> {
463 if let Some(stats) = deferred_clone_stats(path) {
464 stats.skipped.fetch_add(1, Ordering::Relaxed);
465 return Ok(());
466 }
467 file.sync_data()
468}
469
470#[cfg(any(target_os = "linux", target_os = "android"))]
471fn sync_filesystem(path: &Path) -> io::Result<()> {
472 use std::os::fd::AsRawFd;
473
474 let file = OpenOptions::new().read(true).open(path)?;
475 if unsafe { libc::syncfs(file.as_raw_fd()) } == 0 {
477 Ok(())
478 } else {
479 Err(io::Error::last_os_error())
480 }
481}
482
483#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
484fn sync_filesystem(_path: &Path) -> io::Result<()> {
485 unsafe { libc::sync() };
488 Ok(())
489}
490
491#[cfg(windows)]
492fn sync_filesystem(path: &Path) -> io::Result<()> {
493 for entry in fs::read_dir(path)? {
497 let entry = entry?;
498 let file_type = entry.file_type()?;
499 if file_type.is_dir() {
500 sync_filesystem(&entry.path())?;
501 } else if file_type.is_file() {
502 OpenOptions::new()
503 .read(true)
504 .open(entry.path())?
505 .sync_all()?;
506 }
507 }
508 Ok(())
509}
510
511fn plan_missing_dirs(path: &Path) -> (Vec<PathBuf>, Option<PathBuf>) {
515 let mut missing: Vec<PathBuf> = Vec::new();
518 {
519 let mut cur = path;
520 loop {
521 match fs::metadata(cur) {
522 Ok(meta) if meta.is_dir() => break,
523 Ok(_) => {
524 break;
527 }
528 Err(e) if e.kind() == io::ErrorKind::NotFound => {
529 missing.push(cur.to_path_buf());
530 match cur.parent() {
531 Some(parent) if parent.as_os_str().is_empty() => break,
534 Some(parent) if parent != cur => cur = parent,
536 _ => break,
537 }
538 }
539 Err(_) => break,
542 }
543 }
544 }
545
546 let deepest_existing = missing
547 .last()
548 .and_then(|shallowest| match shallowest.parent() {
549 Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
550 Some(parent) => Some(parent.to_path_buf()),
551 None => None,
552 });
553
554 (missing, deepest_existing)
555}
556
557fn sync_new_dirents(missing: &[PathBuf], deepest_existing: Option<&Path>) -> io::Result<()> {
560 if missing.is_empty() {
561 return Ok(());
562 }
563 for dir in missing {
564 sync_directory(dir)?;
565 }
566 if let Some(existing) = deepest_existing {
569 sync_directory(existing)?;
570 }
571 Ok(())
572}
573
574pub fn create_dir_all_durable(path: &Path) -> io::Result<()> {
592 let (missing, deepest_existing) = plan_missing_dirs(path);
593 fs::create_dir_all(path)?;
594 sync_new_dirents(&missing, deepest_existing.as_deref())
595}
596
597fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
607 enrich_fs_error(path, "writing", err)
608}
609
610pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
639 if is_out_of_space(&err) {
640 let msg = format!(
641 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
642 path.display()
643 );
644 return io::Error::new(
645 io::ErrorKind::StorageFull,
646 EnrichedFsError { msg, source: err },
647 );
648 }
649 if is_directory_not_empty(&err) {
650 let msg = format!(
651 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
652 path.display()
653 );
654 return io::Error::new(
655 io::ErrorKind::DirectoryNotEmpty,
656 EnrichedFsError { msg, source: err },
657 );
658 }
659 if is_read_only_filesystem(&err) {
660 let msg = format!(
661 "filesystem is read-only — `{}` cannot be modified",
662 path.display()
663 );
664 return io::Error::new(
665 io::ErrorKind::ReadOnlyFilesystem,
666 EnrichedFsError { msg, source: err },
667 );
668 }
669 if is_permission_denied(&err) {
670 let msg = format!(
671 "permission denied {op} `{}` — check filesystem permissions",
672 path.display()
673 );
674 return io::Error::new(
675 io::ErrorKind::PermissionDenied,
676 EnrichedFsError { msg, source: err },
677 );
678 }
679 if is_not_found(&err) {
680 let msg = format!("could not find `{}` for {op}", path.display());
681 return io::Error::new(
682 io::ErrorKind::NotFound,
683 EnrichedFsError { msg, source: err },
684 );
685 }
686 if is_cross_device_link(&err) {
687 let msg = format!(
688 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
689 path.display()
690 );
691 return io::Error::new(
692 io::ErrorKind::CrossesDevices,
693 EnrichedFsError { msg, source: err },
694 );
695 }
696 err
697}
698
699pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
704 if is_cross_device_link(&err) {
705 let msg = format!(
706 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
707 src.display(),
708 dst.display()
709 );
710 return io::Error::new(
711 io::ErrorKind::CrossesDevices,
712 EnrichedFsError { msg, source: err },
713 );
714 }
715 enrich_fs_error(dst, "renaming", err)
716}
717
718#[derive(Debug)]
719struct EnrichedFsError {
720 msg: String,
721 source: io::Error,
722}
723
724impl std::fmt::Display for EnrichedFsError {
725 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726 f.write_str(&self.msg)
727 }
728}
729
730impl std::error::Error for EnrichedFsError {
731 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
732 Some(&self.source)
733 }
734}
735
736pub struct StagedAtomicWrite {
737 path: PathBuf,
738 tmp: PathBuf,
739 pending: bool,
740}
741
742impl StagedAtomicWrite {
743 pub fn publish(mut self) -> io::Result<()> {
744 durable_rename(&self.tmp, &self.path)
745 .map_err(|error| enrich_rename_error(&self.tmp, &self.path, error))?;
746 self.pending = false;
747 Ok(())
748 }
749}
750
751impl Drop for StagedAtomicWrite {
752 fn drop(&mut self) {
753 if self.pending {
754 let _ = fs::remove_file(&self.tmp);
755 }
756 }
757}
758
759fn stage_file_atomic_impl(
760 path: &Path,
761 bytes: &[u8],
762 kind: AtomicWriteKind,
763 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
764) -> io::Result<StagedAtomicWrite> {
765 let parent = path.parent().unwrap_or_else(|| Path::new("."));
766 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
767
768 let tmp = temp_path(path);
769 let inner = (|| -> io::Result<()> {
770 let mut file = kind.open_tmp(&tmp)?;
771 kind.enforce_before_write(&file)?;
772 before_write(&file, &tmp)?;
773 file.write_all(bytes)?;
774 sync_file(&file, &tmp)?;
775 Ok(())
776 })();
777
778 if let Err(err) = inner {
779 let _ = fs::remove_file(&tmp);
783 return Err(enrich_write_error(path, err));
784 }
785
786 Ok(StagedAtomicWrite {
787 path: path.to_path_buf(),
788 tmp,
789 pending: true,
790 })
791}
792
793fn write_file_atomic_impl(
794 path: &Path,
795 bytes: &[u8],
796 kind: AtomicWriteKind,
797 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
798) -> io::Result<()> {
799 stage_file_atomic_impl(path, bytes, kind, before_write)?.publish()
800}
801
802pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
803 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
804}
805
806pub fn write_file_atomic_reconstructible(path: &Path, bytes: &[u8]) -> io::Result<()> {
811 let parent = path.parent().unwrap_or_else(|| Path::new("."));
812 fs::create_dir_all(parent).map_err(|error| enrich_fs_error(parent, "creating", error))?;
813 let tmp = temp_path(path);
814 let result = (|| -> io::Result<()> {
815 let mut file = AtomicWriteKind::Normal.open_tmp(&tmp)?;
816 file.write_all(bytes)?;
817 file.flush()?;
818 drop(file);
819 fs::rename(&tmp, path).map_err(|error| enrich_rename_error(&tmp, path, error))
820 })();
821 if let Err(error) = result {
822 let _ = fs::remove_file(&tmp);
823 return Err(enrich_write_error(path, error));
824 }
825 Ok(())
826}
827
828pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
840 #[cfg(unix)]
841 {
842 use std::os::unix::fs::DirBuilderExt;
843 let (missing, deepest_existing) = plan_missing_dirs(path);
844 let mut builder = fs::DirBuilder::new();
845 builder.recursive(true).mode(0o700);
846 match builder.create(path) {
847 Ok(()) => {}
848 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
849 Err(e) => return Err(e),
850 }
851 sync_new_dirents(&missing, deepest_existing.as_deref())
852 }
853 #[cfg(not(unix))]
854 {
855 create_dir_all_durable(path)
857 }
858}
859
860pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
870 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
871}
872
873pub fn stage_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<StagedAtomicWrite> {
874 stage_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
875}
876
877fn fsync_file_data(path: &Path) -> io::Result<()> {
904 let file = OpenOptions::new()
905 .read(true)
906 .write(true)
907 .open(path)
908 .map_err(|e| enrich_fs_error(path, "opening", e))?;
909 sync_file(&file, path).map_err(|e| enrich_fs_error(path, "syncing", e))
910}
911
912pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
913 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
914 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
915
916 fsync_file_data(src)?;
921
922 match durable_rename(src, dst) {
923 Ok(()) => {}
924 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
925 let _ = fs::remove_file(src);
927 }
928 Err(e) if is_cross_device_link(&e) => {
929 publish_file_via_copy_durable(src, dst)?;
930 }
931 Err(e) => return Err(enrich_rename_error(src, dst, e)),
932 }
933
934 Ok(())
935}
936
937fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
941 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
942 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
943
944 let tmp = temp_path(dst);
945 let result = (|| -> io::Result<()> {
946 fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
947 fsync_file_data(&tmp)?;
948 durable_rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
949 let _ = fs::remove_file(src);
950 Ok(())
951 })();
952 if result.is_err() {
953 let _ = fs::remove_file(&tmp);
954 }
955 result
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961
962 fn enospc_io_error() -> io::Error {
963 io::Error::from_raw_os_error(ENOSPC)
964 }
965
966 #[test]
967 fn is_out_of_space_detects_enospc_raw() {
968 assert!(is_out_of_space(&enospc_io_error()));
969 }
970
971 #[test]
972 fn is_out_of_space_detects_storage_full_kind() {
973 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
974 assert!(is_out_of_space(&err));
975 }
976
977 #[test]
978 fn is_out_of_space_detects_write_zero() {
979 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
980 assert!(is_out_of_space(&err));
981 }
982
983 #[test]
984 fn is_out_of_space_rejects_unrelated_errors() {
985 assert!(!is_out_of_space(&io::Error::new(
986 io::ErrorKind::NotFound,
987 "missing"
988 )));
989 assert!(!is_out_of_space(&io::Error::new(
990 io::ErrorKind::PermissionDenied,
991 "nope"
992 )));
993 assert!(!is_out_of_space(&io::Error::other("generic")));
994 }
995
996 #[test]
997 fn is_directory_not_empty_detects_kind() {
998 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
999 assert!(is_directory_not_empty(&err));
1000 }
1001
1002 #[test]
1003 fn is_directory_not_empty_detects_raw_codes() {
1004 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
1005 assert!(
1006 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
1007 "expected raw OS error {code} to classify as ENOTEMPTY"
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn is_directory_not_empty_rejects_unrelated() {
1014 assert!(!is_directory_not_empty(&io::Error::new(
1015 io::ErrorKind::NotFound,
1016 "missing"
1017 )));
1018 assert!(!is_directory_not_empty(&enospc_io_error()));
1019 }
1020
1021 #[test]
1022 #[cfg(windows)]
1023 fn windows_rename_contention_is_access_denied_or_sharing_violation() {
1024 assert!(windows_rename_is_contended(&io::Error::from_raw_os_error(
1025 ERROR_ACCESS_DENIED
1026 )));
1027 assert!(windows_rename_is_contended(&io::Error::from_raw_os_error(
1028 ERROR_SHARING_VIOLATION
1029 )));
1030 assert!(!windows_rename_is_contended(&io::Error::from_raw_os_error(
1031 ENOENT
1032 )));
1033 assert!(!windows_rename_is_contended(&io::Error::new(
1034 io::ErrorKind::PermissionDenied,
1035 "no raw code"
1036 )));
1037 }
1038
1039 #[test]
1040 fn is_permission_denied_detects_kind_and_raw() {
1041 assert!(is_permission_denied(&io::Error::new(
1042 io::ErrorKind::PermissionDenied,
1043 "nope"
1044 )));
1045 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
1046 }
1047
1048 #[test]
1049 fn is_not_found_detects_kind_and_raw() {
1050 assert!(is_not_found(&io::Error::new(
1051 io::ErrorKind::NotFound,
1052 "missing"
1053 )));
1054 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
1055 }
1056
1057 #[test]
1058 fn is_read_only_filesystem_detects_raw() {
1059 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
1060 EROFS
1061 )));
1062 }
1063
1064 #[test]
1065 fn is_cross_device_link_detects_raw() {
1066 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
1067 }
1068
1069 #[test]
1070 fn enrich_fs_error_passes_through_unclassified() {
1071 let path = Path::new("/tmp/example");
1072 let original = io::Error::other("weird");
1073 let wrapped = enrich_fs_error(path, "writing", original);
1074 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1076 assert_eq!(wrapped.to_string(), "weird");
1077 }
1078
1079 #[test]
1080 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
1081 let path = Path::new("/repo/.heddle/state/abc.bin");
1082 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
1083
1084 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
1086 let msg = wrapped.to_string();
1088 assert!(
1089 msg.contains("out of disk space"),
1090 "missing failure name: {msg}"
1091 );
1092 assert!(
1093 msg.contains("/repo/.heddle/state/abc.bin"),
1094 "missing path: {msg}"
1095 );
1096 assert!(
1097 msg.contains("free disk space") && msg.contains("re-run"),
1098 "missing recovery hint: {msg}"
1099 );
1100 assert!(
1101 msg.contains("working tree is unchanged"),
1102 "missing reassurance: {msg}"
1103 );
1104 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
1107 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
1108 .expect("source preserved");
1109 assert!(src.to_string().to_lowercase().contains("space"));
1110 }
1111
1112 #[test]
1113 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
1114 let path = Path::new("/repo/web");
1115 let wrapped = enrich_fs_error(
1116 path,
1117 "removing",
1118 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
1119 );
1120 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
1121 let msg = wrapped.to_string();
1122 assert!(
1123 msg.contains("could not remove directory"),
1124 "missing action: {msg}"
1125 );
1126 assert!(msg.contains("/repo/web"), "missing path: {msg}");
1127 assert!(
1128 msg.contains("heddle-ignored"),
1129 "missing heddle-ignored hint: {msg}"
1130 );
1131 assert!(
1132 msg.contains("leaving in place"),
1133 "missing reassurance: {msg}"
1134 );
1135 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
1140 let original = src
1141 .downcast_ref::<io::Error>()
1142 .expect("original io::Error preserved");
1143 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
1144 }
1145
1146 #[test]
1147 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
1148 let path = Path::new("/repo/.heddle/state/index.bin");
1149 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
1150 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1151 let msg = wrapped.to_string();
1152 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
1153 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
1154 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
1155 }
1156
1157 #[test]
1158 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
1159 let path = Path::new("/repo/.heddle");
1160 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
1161 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
1162 let msg = wrapped.to_string();
1163 assert!(msg.contains("could not find"), "missing action: {msg}");
1164 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
1165 assert!(msg.contains("for opening"), "missing op: {msg}");
1166 }
1167
1168 #[test]
1169 fn enrich_fs_error_wraps_erofs_with_path() {
1170 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
1171 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
1172 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
1173 let msg = wrapped.to_string();
1174 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
1175 assert!(
1176 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
1177 "msg: {msg}"
1178 );
1179 assert!(msg.contains("cannot be modified"), "msg: {msg}");
1180 }
1181
1182 #[test]
1183 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
1184 let src = Path::new("/tmp-mount/.x.tmp-1234");
1185 let dst = Path::new("/repo/.heddle/state/index.bin");
1186 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
1187 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
1188 let msg = wrapped.to_string();
1189 assert!(
1190 msg.contains("cannot rename across filesystems"),
1191 "msg: {msg}"
1192 );
1193 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
1194 assert!(
1195 msg.contains("/repo/.heddle/state/index.bin"),
1196 "missing dst: {msg}"
1197 );
1198 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
1199 }
1200
1201 #[test]
1202 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
1203 let src = Path::new("/tmp/.x.tmp");
1204 let dst = Path::new("/repo/file");
1205 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
1206 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1209 let msg = wrapped.to_string();
1210 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
1211 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
1212 }
1213
1214 #[test]
1215 fn enrich_write_error_passes_through_non_enospc_unclassified() {
1216 let path = Path::new("/tmp/example");
1219 let original = io::Error::other("weird");
1220 let wrapped = enrich_write_error(path, original);
1221 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1222 assert_eq!(wrapped.to_string(), "weird");
1223 }
1224
1225 #[test]
1226 fn write_file_atomic_round_trip() {
1227 let dir = tempfile::TempDir::new().unwrap();
1228 let target = dir.path().join("nested/under/here/file.bin");
1229 write_file_atomic(&target, b"hello").unwrap();
1230 assert_eq!(fs::read(&target).unwrap(), b"hello");
1231 }
1232
1233 #[test]
1234 fn stage_temp_files_durable_writes_every_file_verbatim() {
1235 let dir = tempfile::TempDir::new().unwrap();
1239 let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
1240 .map(|i| {
1241 (
1242 dir.path().join(format!("ref-{i}.tmp")),
1243 format!("change-id-{i}\n").into_bytes(),
1244 )
1245 })
1246 .collect();
1247
1248 stage_temp_files_durable(&files).unwrap();
1249
1250 for (path, bytes) in &files {
1251 assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
1252 }
1253 }
1254
1255 #[test]
1256 fn stage_temp_files_durable_empty_batch_is_ok() {
1257 stage_temp_files_durable(&[]).unwrap();
1260 }
1261
1262 #[test]
1263 fn stage_temp_files_durable_errors_when_parent_missing() {
1264 let dir = tempfile::TempDir::new().unwrap();
1268 let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
1269 assert!(stage_temp_files_durable(&files).is_err());
1270 }
1271
1272 #[cfg(unix)]
1273 #[test]
1274 fn create_private_dir_all_sets_0700() {
1275 use std::os::unix::fs::PermissionsExt;
1276
1277 let dir = tempfile::TempDir::new().unwrap();
1278 let target = dir.path().join("nested/private");
1279 create_private_dir_all(&target).expect("create private dir");
1280 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1281 assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
1282 let mid_mode = fs::metadata(dir.path().join("nested"))
1285 .unwrap()
1286 .permissions()
1287 .mode()
1288 & 0o777;
1289 assert_eq!(
1290 mid_mode, 0o700,
1291 "intermediate private ancestor must be 0700"
1292 );
1293 create_private_dir_all(&target).expect("idempotent private create");
1295 let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1296 assert_eq!(mode_again, 0o700);
1297 }
1298
1299 #[cfg(unix)]
1300 #[test]
1301 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1302 use std::os::unix::fs::PermissionsExt;
1303
1304 let dir = tempfile::TempDir::new().unwrap();
1305 let target = dir.path().join("nested/secret.txt");
1306 let mut observed_tmp_mode = None;
1307
1308 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1309 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1310 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1311 observed_tmp_mode = Some((fd_mode, path_mode));
1312 Ok(())
1313 })
1314 .unwrap();
1315
1316 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1317 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1318 assert_eq!(final_mode, 0o600);
1319 assert_eq!(fs::read(&target).unwrap(), b"secret");
1320 }
1321
1322 #[test]
1323 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1324 let dir = tempfile::TempDir::new().unwrap();
1325 let target = dir.path().join("secret.txt");
1326 let mut tmp_path = None;
1327
1328 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1329 tmp_path = Some(tmp.to_path_buf());
1330 Err(io::Error::new(
1331 io::ErrorKind::PermissionDenied,
1332 "injected permission failure",
1333 ))
1334 })
1335 .expect_err("permission failure should propagate");
1336
1337 assert!(is_permission_denied(&err), "unexpected error: {err}");
1338 assert!(!target.exists(), "secret write must not publish target");
1339 let tmp = tmp_path.expect("pre-write hook observed temp path");
1340 assert!(!tmp.exists(), "failed secret write should remove temp file");
1341 }
1342
1343 #[test]
1344 fn staged_secret_is_unpublished_until_publish() {
1345 let dir = tempfile::TempDir::new().unwrap();
1346 let target = dir.path().join("secret.txt");
1347 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1348
1349 assert!(!target.exists());
1350 staged.publish().unwrap();
1351 assert_eq!(fs::read(target).unwrap(), b"secret");
1352 }
1353
1354 #[test]
1355 fn dropping_staged_secret_removes_temporary_file() {
1356 let dir = tempfile::TempDir::new().unwrap();
1357 let target = dir.path().join("secret.txt");
1358 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1359 drop(staged);
1360
1361 assert!(!target.exists());
1362 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
1363 }
1364
1365 #[test]
1374 fn sync_directory_succeeds_on_writable_tempdir() {
1375 let dir = tempfile::TempDir::new().unwrap();
1376 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1377 }
1378
1379 #[test]
1384 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1385 let dir = tempfile::TempDir::new().unwrap();
1386 let target = dir.path().join("oplog/oplog.bin");
1387 let result = write_file_atomic(&target, b"hello");
1388 if let Err(e) = &result {
1389 assert!(
1390 !is_permission_denied(e),
1391 "write_file_atomic surfaced PermissionDenied on a writable \
1392 tempdir (heddle#105): {e}"
1393 );
1394 }
1395 result.expect("write_file_atomic");
1396 }
1397
1398 #[test]
1399 fn publish_file_durable_renames_and_removes_source() {
1400 let dir = tempfile::TempDir::new().unwrap();
1401 let src = dir.path().join("staged.pack");
1402 let dst = dir.path().join("objects/packs/final.pack");
1403 fs::write(&src, b"pack-bytes").unwrap();
1404
1405 publish_file_durable(&src, &dst).unwrap();
1406
1407 assert!(!src.exists(), "source must be consumed by publish");
1408 assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1409 }
1410
1411 #[test]
1415 fn publish_file_durable_syncs_source_without_permission_deny() {
1416 let dir = tempfile::TempDir::new().unwrap();
1417 let src = dir.path().join("staged.bin");
1418 let dst = dir.path().join("final.bin");
1419 fs::write(&src, b"need-fsync-before-rename").unwrap();
1420 let result = publish_file_durable(&src, &dst);
1421 if let Err(e) = &result {
1422 assert!(
1423 !is_permission_denied(e),
1424 "publish_file_durable PermissionDenied on source fsync: {e}"
1425 );
1426 }
1427 result.expect("publish_file_durable");
1428 assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1429 }
1430
1431 #[test]
1432 fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1433 let dir = tempfile::TempDir::new().unwrap();
1439 let src = dir.path().join("staged.pack");
1440 let dst = dir.path().join("final.pack");
1441 fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1444 fs::write(&src, b"complete-new-pack-bytes").unwrap();
1445
1446 publish_file_via_copy_durable(&src, &dst).unwrap();
1447
1448 assert!(!src.exists(), "source must be removed after copy publish");
1449 assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1450 let leftovers: Vec<_> = fs::read_dir(dir.path())
1452 .unwrap()
1453 .filter_map(|e| e.ok())
1454 .map(|e| e.file_name().to_string_lossy().into_owned())
1455 .filter(|name| name.contains(".tmp-"))
1456 .collect();
1457 assert!(
1458 leftovers.is_empty(),
1459 "durable copy must not leave temp siblings: {leftovers:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1465 let dir = tempfile::TempDir::new().unwrap();
1470 let src = dir.path().join("staged.pack");
1471 let dst_dir = dir.path().join("final.pack");
1472 fs::write(&src, b"new-bytes").unwrap();
1473 fs::create_dir(&dst_dir).unwrap();
1477
1478 let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1479 assert!(
1480 err.kind() == io::ErrorKind::AlreadyExists
1481 || err.raw_os_error().is_some()
1482 || is_permission_denied(&err)
1483 || err.kind() == io::ErrorKind::Other
1484 || err.kind() == io::ErrorKind::IsADirectory
1485 || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1486 "unexpected error kind for rename-over-dir: {err:?}"
1487 );
1488 assert!(src.exists(), "failed publish must leave source intact");
1489 assert!(dst_dir.is_dir(), "destination directory must be untouched");
1490 let leftovers: Vec<_> = fs::read_dir(dir.path())
1491 .unwrap()
1492 .filter_map(|e| e.ok())
1493 .map(|e| e.file_name().to_string_lossy().into_owned())
1494 .filter(|name| name.contains(".tmp-"))
1495 .collect();
1496 assert!(
1497 leftovers.is_empty(),
1498 "failed publish must clean temp siblings: {leftovers:?}"
1499 );
1500 }
1501
1502 #[test]
1503 fn publish_file_durable_propagates_non_exdev_rename_failures() {
1504 let dir = tempfile::TempDir::new().unwrap();
1509 let src = dir.path().join("staged.pack");
1510 let dst = dir.path().join("final.pack");
1511 fs::write(&src, b"pack-bytes").unwrap();
1512 fs::create_dir(&dst).unwrap();
1513
1514 let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1515 assert!(
1516 !is_cross_device_link(&err),
1517 "failure must not be misclassified as EXDEV: {err}"
1518 );
1519 assert!(src.exists());
1521 }
1522
1523 #[test]
1527 fn create_dir_all_durable_creates_nested_path() {
1528 let dir = tempfile::TempDir::new().unwrap();
1529 let shard = dir.path().join("blobs/ab");
1532 create_dir_all_durable(&shard).expect("create nested shard path");
1533 assert!(shard.is_dir(), "leaf shard directory must exist");
1534 assert!(
1535 dir.path().join("blobs").is_dir(),
1536 "intermediate grandparent must exist"
1537 );
1538 create_dir_all_durable(&shard).expect("idempotent durable create");
1540 assert!(shard.is_dir());
1541 }
1542
1543 #[test]
1547 fn write_file_atomic_creates_missing_shard_parents() {
1548 let dir = tempfile::TempDir::new().unwrap();
1549 let target = dir.path().join("blobs/ab/object.bin");
1550 write_file_atomic(&target, b"shard-bytes").unwrap();
1551 assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1552 assert!(dir.path().join("blobs/ab").is_dir());
1553 }
1554
1555 #[test]
1557 fn create_dir_all_durable_ok_when_path_already_exists() {
1558 let dir = tempfile::TempDir::new().unwrap();
1559 let nested = dir.path().join("already/there");
1560 fs::create_dir_all(&nested).unwrap();
1561 create_dir_all_durable(&nested).expect("existing dir");
1562 assert!(nested.is_dir());
1563 }
1564}