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
188pub fn is_out_of_space(err: &io::Error) -> bool {
193 if err.raw_os_error() == Some(ENOSPC) {
194 return true;
195 }
196 if err.kind() == io::ErrorKind::StorageFull {
200 return true;
201 }
202 if err.kind() == io::ErrorKind::WriteZero {
208 return true;
209 }
210 false
211}
212
213pub fn is_directory_not_empty(err: &io::Error) -> bool {
221 if err.kind() == io::ErrorKind::DirectoryNotEmpty {
222 return true;
223 }
224 matches!(
225 err.raw_os_error(),
226 Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
227 )
228}
229
230pub fn is_permission_denied(err: &io::Error) -> bool {
236 if err.kind() == io::ErrorKind::PermissionDenied {
237 return true;
238 }
239 err.raw_os_error() == Some(EACCES)
240}
241
242pub fn is_not_found(err: &io::Error) -> bool {
248 if err.kind() == io::ErrorKind::NotFound {
249 return true;
250 }
251 err.raw_os_error() == Some(ENOENT)
252}
253
254pub fn is_read_only_filesystem(err: &io::Error) -> bool {
260 if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
261 return true;
262 }
263 err.raw_os_error() == Some(EROFS)
264}
265
266pub fn is_cross_device_link(err: &io::Error) -> bool {
273 if err.kind() == io::ErrorKind::CrossesDevices {
274 return true;
275 }
276 err.raw_os_error() == Some(EXDEV)
277}
278
279pub fn temp_path(path: &Path) -> PathBuf {
280 let parent = path.parent().unwrap_or_else(|| Path::new("."));
281 let file_name = path
282 .file_name()
283 .and_then(|s| s.to_str())
284 .filter(|s| !s.is_empty())
285 .unwrap_or("heddle-tmp");
286 let unique = SystemTime::now()
287 .duration_since(UNIX_EPOCH)
288 .map(|d| d.as_nanos())
289 .unwrap_or(0);
290 let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
291 let pid = std::process::id();
292 parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
293}
294
295#[cfg(target_os = "linux")]
304fn kick_writeback(file: &File) {
305 use std::os::unix::io::AsRawFd;
306 const SYNC_FILE_RANGE_WRITE: libc::c_uint = 2;
310 unsafe {
311 libc::sync_file_range(file.as_raw_fd(), 0, 0, SYNC_FILE_RANGE_WRITE);
312 }
313}
314
315#[cfg(not(target_os = "linux"))]
316fn kick_writeback(_file: &File) {}
317
318pub fn stage_temp_files_durable(files: &[(PathBuf, Vec<u8>)]) -> io::Result<()> {
338 let mut handles: Vec<File> = Vec::with_capacity(files.len());
339 for (temp_path, bytes) in files {
340 let mut file = File::create(temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
341 file.write_all(bytes)
342 .map_err(|err| enrich_write_error(temp_path, err))?;
343 kick_writeback(&file);
344 handles.push(file);
345 }
346 for (file, (temp_path, _)) in handles.iter().zip(files) {
349 sync_file(file, temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
350 }
351 Ok(())
352}
353
354#[cfg(windows)]
378pub fn sync_directory(_path: &Path) -> io::Result<()> {
379 Ok(())
380}
381
382#[cfg(not(windows))]
383pub fn sync_directory(path: &Path) -> io::Result<()> {
384 if let Some(stats) = deferred_clone_stats(path) {
385 stats.skipped.fetch_add(1, Ordering::Relaxed);
386 return Ok(());
387 }
388 let dir = OpenOptions::new().read(true).open(path)?;
389 dir.sync_all()
390}
391
392pub fn sync_file(file: &File, path: &Path) -> io::Result<()> {
394 if let Some(stats) = deferred_clone_stats(path) {
395 stats.skipped.fetch_add(1, Ordering::Relaxed);
396 return Ok(());
397 }
398 file.sync_all()
399}
400
401pub fn sync_file_data(file: &File, path: &Path) -> io::Result<()> {
402 if let Some(stats) = deferred_clone_stats(path) {
403 stats.skipped.fetch_add(1, Ordering::Relaxed);
404 return Ok(());
405 }
406 file.sync_data()
407}
408
409#[cfg(any(target_os = "linux", target_os = "android"))]
410fn sync_filesystem(path: &Path) -> io::Result<()> {
411 use std::os::fd::AsRawFd;
412
413 let file = OpenOptions::new().read(true).open(path)?;
414 if unsafe { libc::syncfs(file.as_raw_fd()) } == 0 {
416 Ok(())
417 } else {
418 Err(io::Error::last_os_error())
419 }
420}
421
422#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
423fn sync_filesystem(_path: &Path) -> io::Result<()> {
424 unsafe { libc::sync() };
427 Ok(())
428}
429
430#[cfg(windows)]
431fn sync_filesystem(path: &Path) -> io::Result<()> {
432 for entry in fs::read_dir(path)? {
436 let entry = entry?;
437 let file_type = entry.file_type()?;
438 if file_type.is_dir() {
439 sync_filesystem(&entry.path())?;
440 } else if file_type.is_file() {
441 OpenOptions::new()
442 .read(true)
443 .open(entry.path())?
444 .sync_all()?;
445 }
446 }
447 Ok(())
448}
449
450fn plan_missing_dirs(path: &Path) -> (Vec<PathBuf>, Option<PathBuf>) {
454 let mut missing: Vec<PathBuf> = Vec::new();
457 {
458 let mut cur = path;
459 loop {
460 match fs::metadata(cur) {
461 Ok(meta) if meta.is_dir() => break,
462 Ok(_) => {
463 break;
466 }
467 Err(e) if e.kind() == io::ErrorKind::NotFound => {
468 missing.push(cur.to_path_buf());
469 match cur.parent() {
470 Some(parent) if parent.as_os_str().is_empty() => break,
473 Some(parent) if parent != cur => cur = parent,
475 _ => break,
476 }
477 }
478 Err(_) => break,
481 }
482 }
483 }
484
485 let deepest_existing = missing
486 .last()
487 .and_then(|shallowest| match shallowest.parent() {
488 Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
489 Some(parent) => Some(parent.to_path_buf()),
490 None => None,
491 });
492
493 (missing, deepest_existing)
494}
495
496fn sync_new_dirents(missing: &[PathBuf], deepest_existing: Option<&Path>) -> io::Result<()> {
499 if missing.is_empty() {
500 return Ok(());
501 }
502 for dir in missing {
503 sync_directory(dir)?;
504 }
505 if let Some(existing) = deepest_existing {
508 sync_directory(existing)?;
509 }
510 Ok(())
511}
512
513pub fn create_dir_all_durable(path: &Path) -> io::Result<()> {
531 let (missing, deepest_existing) = plan_missing_dirs(path);
532 fs::create_dir_all(path)?;
533 sync_new_dirents(&missing, deepest_existing.as_deref())
534}
535
536fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
546 enrich_fs_error(path, "writing", err)
547}
548
549pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
578 if is_out_of_space(&err) {
579 let msg = format!(
580 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
581 path.display()
582 );
583 return io::Error::new(
584 io::ErrorKind::StorageFull,
585 EnrichedFsError { msg, source: err },
586 );
587 }
588 if is_directory_not_empty(&err) {
589 let msg = format!(
590 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
591 path.display()
592 );
593 return io::Error::new(
594 io::ErrorKind::DirectoryNotEmpty,
595 EnrichedFsError { msg, source: err },
596 );
597 }
598 if is_read_only_filesystem(&err) {
599 let msg = format!(
600 "filesystem is read-only — `{}` cannot be modified",
601 path.display()
602 );
603 return io::Error::new(
604 io::ErrorKind::ReadOnlyFilesystem,
605 EnrichedFsError { msg, source: err },
606 );
607 }
608 if is_permission_denied(&err) {
609 let msg = format!(
610 "permission denied {op} `{}` — check filesystem permissions",
611 path.display()
612 );
613 return io::Error::new(
614 io::ErrorKind::PermissionDenied,
615 EnrichedFsError { msg, source: err },
616 );
617 }
618 if is_not_found(&err) {
619 let msg = format!("could not find `{}` for {op}", path.display());
620 return io::Error::new(
621 io::ErrorKind::NotFound,
622 EnrichedFsError { msg, source: err },
623 );
624 }
625 if is_cross_device_link(&err) {
626 let msg = format!(
627 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
628 path.display()
629 );
630 return io::Error::new(
631 io::ErrorKind::CrossesDevices,
632 EnrichedFsError { msg, source: err },
633 );
634 }
635 err
636}
637
638pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
643 if is_cross_device_link(&err) {
644 let msg = format!(
645 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
646 src.display(),
647 dst.display()
648 );
649 return io::Error::new(
650 io::ErrorKind::CrossesDevices,
651 EnrichedFsError { msg, source: err },
652 );
653 }
654 enrich_fs_error(dst, "renaming", err)
655}
656
657#[derive(Debug)]
658struct EnrichedFsError {
659 msg: String,
660 source: io::Error,
661}
662
663impl std::fmt::Display for EnrichedFsError {
664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665 f.write_str(&self.msg)
666 }
667}
668
669impl std::error::Error for EnrichedFsError {
670 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
671 Some(&self.source)
672 }
673}
674
675pub struct StagedAtomicWrite {
676 path: PathBuf,
677 parent: PathBuf,
678 tmp: PathBuf,
679 pending: bool,
680}
681
682impl StagedAtomicWrite {
683 pub fn publish(mut self) -> io::Result<()> {
684 fs::rename(&self.tmp, &self.path)
685 .map_err(|error| enrich_rename_error(&self.tmp, &self.path, error))?;
686 self.pending = false;
687 sync_directory(&self.parent)
688 .map_err(|error| enrich_fs_error(&self.parent, "syncing", error))
689 }
690}
691
692impl Drop for StagedAtomicWrite {
693 fn drop(&mut self) {
694 if self.pending {
695 let _ = fs::remove_file(&self.tmp);
696 }
697 }
698}
699
700fn stage_file_atomic_impl(
701 path: &Path,
702 bytes: &[u8],
703 kind: AtomicWriteKind,
704 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
705) -> io::Result<StagedAtomicWrite> {
706 let parent = path.parent().unwrap_or_else(|| Path::new("."));
707 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
708
709 let tmp = temp_path(path);
710 let inner = (|| -> io::Result<()> {
711 let mut file = kind.open_tmp(&tmp)?;
712 kind.enforce_before_write(&file)?;
713 before_write(&file, &tmp)?;
714 file.write_all(bytes)?;
715 sync_file(&file, &tmp)?;
716 Ok(())
717 })();
718
719 if let Err(err) = inner {
720 let _ = fs::remove_file(&tmp);
724 return Err(enrich_write_error(path, err));
725 }
726
727 Ok(StagedAtomicWrite {
728 path: path.to_path_buf(),
729 parent: parent.to_path_buf(),
730 tmp,
731 pending: true,
732 })
733}
734
735fn write_file_atomic_impl(
736 path: &Path,
737 bytes: &[u8],
738 kind: AtomicWriteKind,
739 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
740) -> io::Result<()> {
741 stage_file_atomic_impl(path, bytes, kind, before_write)?.publish()
742}
743
744pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
745 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
746}
747
748pub fn write_file_atomic_reconstructible(path: &Path, bytes: &[u8]) -> io::Result<()> {
753 let parent = path.parent().unwrap_or_else(|| Path::new("."));
754 fs::create_dir_all(parent).map_err(|error| enrich_fs_error(parent, "creating", error))?;
755 let tmp = temp_path(path);
756 let result = (|| -> io::Result<()> {
757 let mut file = AtomicWriteKind::Normal.open_tmp(&tmp)?;
758 file.write_all(bytes)?;
759 file.flush()?;
760 drop(file);
761 fs::rename(&tmp, path).map_err(|error| enrich_rename_error(&tmp, path, error))
762 })();
763 if let Err(error) = result {
764 let _ = fs::remove_file(&tmp);
765 return Err(enrich_write_error(path, error));
766 }
767 Ok(())
768}
769
770pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
782 #[cfg(unix)]
783 {
784 use std::os::unix::fs::DirBuilderExt;
785 let (missing, deepest_existing) = plan_missing_dirs(path);
786 let mut builder = fs::DirBuilder::new();
787 builder.recursive(true).mode(0o700);
788 match builder.create(path) {
789 Ok(()) => {}
790 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
791 Err(e) => return Err(e),
792 }
793 sync_new_dirents(&missing, deepest_existing.as_deref())
794 }
795 #[cfg(not(unix))]
796 {
797 create_dir_all_durable(path)
799 }
800}
801
802pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
812 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
813}
814
815pub fn stage_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<StagedAtomicWrite> {
816 stage_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
817}
818
819fn fsync_file_data(path: &Path) -> io::Result<()> {
846 let file = OpenOptions::new()
847 .read(true)
848 .write(true)
849 .open(path)
850 .map_err(|e| enrich_fs_error(path, "opening", e))?;
851 sync_file(&file, path).map_err(|e| enrich_fs_error(path, "syncing", e))
852}
853
854pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
855 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
856 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
857
858 fsync_file_data(src)?;
863
864 match fs::rename(src, dst) {
865 Ok(()) => {}
866 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
867 let _ = fs::remove_file(src);
869 }
870 Err(e) if is_cross_device_link(&e) => {
871 publish_file_via_copy_durable(src, dst)?;
872 }
873 Err(e) => return Err(enrich_rename_error(src, dst, e)),
874 }
875
876 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
877}
878
879fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
883 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
884 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
885
886 let tmp = temp_path(dst);
887 let result = (|| -> io::Result<()> {
888 fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
889 fsync_file_data(&tmp)?;
890 fs::rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
891 let _ = fs::remove_file(src);
892 Ok(())
893 })();
894 if result.is_err() {
895 let _ = fs::remove_file(&tmp);
896 }
897 result
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 fn enospc_io_error() -> io::Error {
905 io::Error::from_raw_os_error(ENOSPC)
906 }
907
908 #[test]
909 fn is_out_of_space_detects_enospc_raw() {
910 assert!(is_out_of_space(&enospc_io_error()));
911 }
912
913 #[test]
914 fn is_out_of_space_detects_storage_full_kind() {
915 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
916 assert!(is_out_of_space(&err));
917 }
918
919 #[test]
920 fn is_out_of_space_detects_write_zero() {
921 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
922 assert!(is_out_of_space(&err));
923 }
924
925 #[test]
926 fn is_out_of_space_rejects_unrelated_errors() {
927 assert!(!is_out_of_space(&io::Error::new(
928 io::ErrorKind::NotFound,
929 "missing"
930 )));
931 assert!(!is_out_of_space(&io::Error::new(
932 io::ErrorKind::PermissionDenied,
933 "nope"
934 )));
935 assert!(!is_out_of_space(&io::Error::other("generic")));
936 }
937
938 #[test]
939 fn is_directory_not_empty_detects_kind() {
940 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
941 assert!(is_directory_not_empty(&err));
942 }
943
944 #[test]
945 fn is_directory_not_empty_detects_raw_codes() {
946 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
947 assert!(
948 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
949 "expected raw OS error {code} to classify as ENOTEMPTY"
950 );
951 }
952 }
953
954 #[test]
955 fn is_directory_not_empty_rejects_unrelated() {
956 assert!(!is_directory_not_empty(&io::Error::new(
957 io::ErrorKind::NotFound,
958 "missing"
959 )));
960 assert!(!is_directory_not_empty(&enospc_io_error()));
961 }
962
963 #[test]
964 fn is_permission_denied_detects_kind_and_raw() {
965 assert!(is_permission_denied(&io::Error::new(
966 io::ErrorKind::PermissionDenied,
967 "nope"
968 )));
969 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
970 }
971
972 #[test]
973 fn is_not_found_detects_kind_and_raw() {
974 assert!(is_not_found(&io::Error::new(
975 io::ErrorKind::NotFound,
976 "missing"
977 )));
978 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
979 }
980
981 #[test]
982 fn is_read_only_filesystem_detects_raw() {
983 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
984 EROFS
985 )));
986 }
987
988 #[test]
989 fn is_cross_device_link_detects_raw() {
990 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
991 }
992
993 #[test]
994 fn enrich_fs_error_passes_through_unclassified() {
995 let path = Path::new("/tmp/example");
996 let original = io::Error::other("weird");
997 let wrapped = enrich_fs_error(path, "writing", original);
998 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1000 assert_eq!(wrapped.to_string(), "weird");
1001 }
1002
1003 #[test]
1004 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
1005 let path = Path::new("/repo/.heddle/state/abc.bin");
1006 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
1007
1008 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
1010 let msg = wrapped.to_string();
1012 assert!(
1013 msg.contains("out of disk space"),
1014 "missing failure name: {msg}"
1015 );
1016 assert!(
1017 msg.contains("/repo/.heddle/state/abc.bin"),
1018 "missing path: {msg}"
1019 );
1020 assert!(
1021 msg.contains("free disk space") && msg.contains("re-run"),
1022 "missing recovery hint: {msg}"
1023 );
1024 assert!(
1025 msg.contains("working tree is unchanged"),
1026 "missing reassurance: {msg}"
1027 );
1028 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
1031 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
1032 .expect("source preserved");
1033 assert!(src.to_string().to_lowercase().contains("space"));
1034 }
1035
1036 #[test]
1037 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
1038 let path = Path::new("/repo/web");
1039 let wrapped = enrich_fs_error(
1040 path,
1041 "removing",
1042 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
1043 );
1044 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
1045 let msg = wrapped.to_string();
1046 assert!(
1047 msg.contains("could not remove directory"),
1048 "missing action: {msg}"
1049 );
1050 assert!(msg.contains("/repo/web"), "missing path: {msg}");
1051 assert!(
1052 msg.contains("heddle-ignored"),
1053 "missing heddle-ignored hint: {msg}"
1054 );
1055 assert!(
1056 msg.contains("leaving in place"),
1057 "missing reassurance: {msg}"
1058 );
1059 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
1064 let original = src
1065 .downcast_ref::<io::Error>()
1066 .expect("original io::Error preserved");
1067 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
1068 }
1069
1070 #[test]
1071 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
1072 let path = Path::new("/repo/.heddle/state/index.bin");
1073 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
1074 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1075 let msg = wrapped.to_string();
1076 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
1077 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
1078 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
1079 }
1080
1081 #[test]
1082 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
1083 let path = Path::new("/repo/.heddle");
1084 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
1085 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
1086 let msg = wrapped.to_string();
1087 assert!(msg.contains("could not find"), "missing action: {msg}");
1088 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
1089 assert!(msg.contains("for opening"), "missing op: {msg}");
1090 }
1091
1092 #[test]
1093 fn enrich_fs_error_wraps_erofs_with_path() {
1094 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
1095 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
1096 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
1097 let msg = wrapped.to_string();
1098 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
1099 assert!(
1100 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
1101 "msg: {msg}"
1102 );
1103 assert!(msg.contains("cannot be modified"), "msg: {msg}");
1104 }
1105
1106 #[test]
1107 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
1108 let src = Path::new("/tmp-mount/.x.tmp-1234");
1109 let dst = Path::new("/repo/.heddle/state/index.bin");
1110 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
1111 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
1112 let msg = wrapped.to_string();
1113 assert!(
1114 msg.contains("cannot rename across filesystems"),
1115 "msg: {msg}"
1116 );
1117 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
1118 assert!(
1119 msg.contains("/repo/.heddle/state/index.bin"),
1120 "missing dst: {msg}"
1121 );
1122 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
1123 }
1124
1125 #[test]
1126 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
1127 let src = Path::new("/tmp/.x.tmp");
1128 let dst = Path::new("/repo/file");
1129 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
1130 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1133 let msg = wrapped.to_string();
1134 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
1135 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
1136 }
1137
1138 #[test]
1139 fn enrich_write_error_passes_through_non_enospc_unclassified() {
1140 let path = Path::new("/tmp/example");
1143 let original = io::Error::other("weird");
1144 let wrapped = enrich_write_error(path, original);
1145 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1146 assert_eq!(wrapped.to_string(), "weird");
1147 }
1148
1149 #[test]
1150 fn write_file_atomic_round_trip() {
1151 let dir = tempfile::TempDir::new().unwrap();
1152 let target = dir.path().join("nested/under/here/file.bin");
1153 write_file_atomic(&target, b"hello").unwrap();
1154 assert_eq!(fs::read(&target).unwrap(), b"hello");
1155 }
1156
1157 #[test]
1158 fn stage_temp_files_durable_writes_every_file_verbatim() {
1159 let dir = tempfile::TempDir::new().unwrap();
1163 let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
1164 .map(|i| {
1165 (
1166 dir.path().join(format!("ref-{i}.tmp")),
1167 format!("change-id-{i}\n").into_bytes(),
1168 )
1169 })
1170 .collect();
1171
1172 stage_temp_files_durable(&files).unwrap();
1173
1174 for (path, bytes) in &files {
1175 assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
1176 }
1177 }
1178
1179 #[test]
1180 fn stage_temp_files_durable_empty_batch_is_ok() {
1181 stage_temp_files_durable(&[]).unwrap();
1184 }
1185
1186 #[test]
1187 fn stage_temp_files_durable_errors_when_parent_missing() {
1188 let dir = tempfile::TempDir::new().unwrap();
1192 let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
1193 assert!(stage_temp_files_durable(&files).is_err());
1194 }
1195
1196 #[cfg(unix)]
1197 #[test]
1198 fn create_private_dir_all_sets_0700() {
1199 use std::os::unix::fs::PermissionsExt;
1200
1201 let dir = tempfile::TempDir::new().unwrap();
1202 let target = dir.path().join("nested/private");
1203 create_private_dir_all(&target).expect("create private dir");
1204 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1205 assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
1206 let mid_mode = fs::metadata(dir.path().join("nested"))
1209 .unwrap()
1210 .permissions()
1211 .mode()
1212 & 0o777;
1213 assert_eq!(
1214 mid_mode, 0o700,
1215 "intermediate private ancestor must be 0700"
1216 );
1217 create_private_dir_all(&target).expect("idempotent private create");
1219 let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1220 assert_eq!(mode_again, 0o700);
1221 }
1222
1223 #[cfg(unix)]
1224 #[test]
1225 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1226 use std::os::unix::fs::PermissionsExt;
1227
1228 let dir = tempfile::TempDir::new().unwrap();
1229 let target = dir.path().join("nested/secret.txt");
1230 let mut observed_tmp_mode = None;
1231
1232 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1233 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1234 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1235 observed_tmp_mode = Some((fd_mode, path_mode));
1236 Ok(())
1237 })
1238 .unwrap();
1239
1240 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1241 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1242 assert_eq!(final_mode, 0o600);
1243 assert_eq!(fs::read(&target).unwrap(), b"secret");
1244 }
1245
1246 #[test]
1247 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1248 let dir = tempfile::TempDir::new().unwrap();
1249 let target = dir.path().join("secret.txt");
1250 let mut tmp_path = None;
1251
1252 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1253 tmp_path = Some(tmp.to_path_buf());
1254 Err(io::Error::new(
1255 io::ErrorKind::PermissionDenied,
1256 "injected permission failure",
1257 ))
1258 })
1259 .expect_err("permission failure should propagate");
1260
1261 assert!(is_permission_denied(&err), "unexpected error: {err}");
1262 assert!(!target.exists(), "secret write must not publish target");
1263 let tmp = tmp_path.expect("pre-write hook observed temp path");
1264 assert!(!tmp.exists(), "failed secret write should remove temp file");
1265 }
1266
1267 #[test]
1268 fn staged_secret_is_unpublished_until_publish() {
1269 let dir = tempfile::TempDir::new().unwrap();
1270 let target = dir.path().join("secret.txt");
1271 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1272
1273 assert!(!target.exists());
1274 staged.publish().unwrap();
1275 assert_eq!(fs::read(target).unwrap(), b"secret");
1276 }
1277
1278 #[test]
1279 fn dropping_staged_secret_removes_temporary_file() {
1280 let dir = tempfile::TempDir::new().unwrap();
1281 let target = dir.path().join("secret.txt");
1282 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1283 drop(staged);
1284
1285 assert!(!target.exists());
1286 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
1287 }
1288
1289 #[test]
1298 fn sync_directory_succeeds_on_writable_tempdir() {
1299 let dir = tempfile::TempDir::new().unwrap();
1300 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1301 }
1302
1303 #[test]
1308 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1309 let dir = tempfile::TempDir::new().unwrap();
1310 let target = dir.path().join("oplog/oplog.bin");
1311 let result = write_file_atomic(&target, b"hello");
1312 if let Err(e) = &result {
1313 assert!(
1314 !is_permission_denied(e),
1315 "write_file_atomic surfaced PermissionDenied on a writable \
1316 tempdir (heddle#105): {e}"
1317 );
1318 }
1319 result.expect("write_file_atomic");
1320 }
1321
1322 #[test]
1323 fn publish_file_durable_renames_and_removes_source() {
1324 let dir = tempfile::TempDir::new().unwrap();
1325 let src = dir.path().join("staged.pack");
1326 let dst = dir.path().join("objects/packs/final.pack");
1327 fs::write(&src, b"pack-bytes").unwrap();
1328
1329 publish_file_durable(&src, &dst).unwrap();
1330
1331 assert!(!src.exists(), "source must be consumed by publish");
1332 assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1333 }
1334
1335 #[test]
1339 fn publish_file_durable_syncs_source_without_permission_deny() {
1340 let dir = tempfile::TempDir::new().unwrap();
1341 let src = dir.path().join("staged.bin");
1342 let dst = dir.path().join("final.bin");
1343 fs::write(&src, b"need-fsync-before-rename").unwrap();
1344 let result = publish_file_durable(&src, &dst);
1345 if let Err(e) = &result {
1346 assert!(
1347 !is_permission_denied(e),
1348 "publish_file_durable PermissionDenied on source fsync: {e}"
1349 );
1350 }
1351 result.expect("publish_file_durable");
1352 assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1353 }
1354
1355 #[test]
1356 fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1357 let dir = tempfile::TempDir::new().unwrap();
1363 let src = dir.path().join("staged.pack");
1364 let dst = dir.path().join("final.pack");
1365 fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1368 fs::write(&src, b"complete-new-pack-bytes").unwrap();
1369
1370 publish_file_via_copy_durable(&src, &dst).unwrap();
1371
1372 assert!(!src.exists(), "source must be removed after copy publish");
1373 assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1374 let leftovers: Vec<_> = fs::read_dir(dir.path())
1376 .unwrap()
1377 .filter_map(|e| e.ok())
1378 .map(|e| e.file_name().to_string_lossy().into_owned())
1379 .filter(|name| name.contains(".tmp-"))
1380 .collect();
1381 assert!(
1382 leftovers.is_empty(),
1383 "durable copy must not leave temp siblings: {leftovers:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1389 let dir = tempfile::TempDir::new().unwrap();
1394 let src = dir.path().join("staged.pack");
1395 let dst_dir = dir.path().join("final.pack");
1396 fs::write(&src, b"new-bytes").unwrap();
1397 fs::create_dir(&dst_dir).unwrap();
1401
1402 let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1403 assert!(
1404 err.kind() == io::ErrorKind::AlreadyExists
1405 || err.raw_os_error().is_some()
1406 || is_permission_denied(&err)
1407 || err.kind() == io::ErrorKind::Other
1408 || err.kind() == io::ErrorKind::IsADirectory
1409 || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1410 "unexpected error kind for rename-over-dir: {err:?}"
1411 );
1412 assert!(src.exists(), "failed publish must leave source intact");
1413 assert!(dst_dir.is_dir(), "destination directory must be untouched");
1414 let leftovers: Vec<_> = fs::read_dir(dir.path())
1415 .unwrap()
1416 .filter_map(|e| e.ok())
1417 .map(|e| e.file_name().to_string_lossy().into_owned())
1418 .filter(|name| name.contains(".tmp-"))
1419 .collect();
1420 assert!(
1421 leftovers.is_empty(),
1422 "failed publish must clean temp siblings: {leftovers:?}"
1423 );
1424 }
1425
1426 #[test]
1427 fn publish_file_durable_propagates_non_exdev_rename_failures() {
1428 let dir = tempfile::TempDir::new().unwrap();
1433 let src = dir.path().join("staged.pack");
1434 let dst = dir.path().join("final.pack");
1435 fs::write(&src, b"pack-bytes").unwrap();
1436 fs::create_dir(&dst).unwrap();
1437
1438 let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1439 assert!(
1440 !is_cross_device_link(&err),
1441 "failure must not be misclassified as EXDEV: {err}"
1442 );
1443 assert!(src.exists());
1445 }
1446
1447 #[test]
1451 fn create_dir_all_durable_creates_nested_path() {
1452 let dir = tempfile::TempDir::new().unwrap();
1453 let shard = dir.path().join("blobs/ab");
1456 create_dir_all_durable(&shard).expect("create nested shard path");
1457 assert!(shard.is_dir(), "leaf shard directory must exist");
1458 assert!(
1459 dir.path().join("blobs").is_dir(),
1460 "intermediate grandparent must exist"
1461 );
1462 create_dir_all_durable(&shard).expect("idempotent durable create");
1464 assert!(shard.is_dir());
1465 }
1466
1467 #[test]
1471 fn write_file_atomic_creates_missing_shard_parents() {
1472 let dir = tempfile::TempDir::new().unwrap();
1473 let target = dir.path().join("blobs/ab/object.bin");
1474 write_file_atomic(&target, b"shard-bytes").unwrap();
1475 assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1476 assert!(dir.path().join("blobs/ab").is_dir());
1477 }
1478
1479 #[test]
1481 fn create_dir_all_durable_ok_when_path_already_exists() {
1482 let dir = tempfile::TempDir::new().unwrap();
1483 let nested = dir.path().join("already/there");
1484 fs::create_dir_all(&nested).unwrap();
1485 create_dir_all_durable(&nested).expect("existing dir");
1486 assert!(nested.is_dir());
1487 }
1488}