1use std::{
3 fs::{self, File, OpenOptions},
4 io::{self, Write},
5 path::{Path, PathBuf},
6 sync::atomic::{AtomicU64, Ordering},
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10#[derive(Clone, Copy)]
11enum AtomicWriteKind {
12 Normal,
13 Secret,
14}
15
16impl AtomicWriteKind {
17 fn open_tmp(self, tmp: &Path) -> io::Result<File> {
18 let mut options = OpenOptions::new();
19 options.create_new(true).write(true);
20
21 #[cfg(unix)]
22 if matches!(self, Self::Secret) {
23 use std::os::unix::fs::OpenOptionsExt;
24 options.mode(0o600);
25 }
26
27 options.open(tmp)
28 }
29
30 fn enforce_before_write(self, file: &File) -> io::Result<()> {
31 match self {
32 Self::Normal => Ok(()),
33 Self::Secret => enforce_secret_permissions_before_write(file),
34 }
35 }
36}
37
38#[cfg(unix)]
39fn enforce_secret_permissions_before_write(file: &File) -> io::Result<()> {
40 use std::os::unix::fs::PermissionsExt;
41
42 file.set_permissions(fs::Permissions::from_mode(0o600))?;
43 let mode = file.metadata()?.permissions().mode() & 0o777;
44 if mode != 0o600 {
45 return Err(io::Error::new(
46 io::ErrorKind::PermissionDenied,
47 format!("secret temp file permissions are {mode:o}, expected 600"),
48 ));
49 }
50 Ok(())
51}
52
53#[cfg(not(unix))]
54fn enforce_secret_permissions_before_write(_file: &File) -> io::Result<()> {
55 Ok(())
59}
60
61static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
62
63const ENOSPC: i32 = 28;
68
69const ENOTEMPTY_LINUX: i32 = 39;
74const ENOTEMPTY_MACOS: i32 = 66;
75const ENOTEMPTY_WINDOWS: i32 = 145;
76
77const EACCES: i32 = 13;
80
81const ENOENT: i32 = 2;
84
85const EROFS: i32 = 30;
88
89const EXDEV: i32 = 18;
92
93pub fn is_out_of_space(err: &io::Error) -> bool {
98 if err.raw_os_error() == Some(ENOSPC) {
99 return true;
100 }
101 if err.kind() == io::ErrorKind::StorageFull {
105 return true;
106 }
107 if err.kind() == io::ErrorKind::WriteZero {
113 return true;
114 }
115 false
116}
117
118pub fn is_directory_not_empty(err: &io::Error) -> bool {
126 if err.kind() == io::ErrorKind::DirectoryNotEmpty {
127 return true;
128 }
129 matches!(
130 err.raw_os_error(),
131 Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
132 )
133}
134
135pub fn is_permission_denied(err: &io::Error) -> bool {
141 if err.kind() == io::ErrorKind::PermissionDenied {
142 return true;
143 }
144 err.raw_os_error() == Some(EACCES)
145}
146
147pub fn is_not_found(err: &io::Error) -> bool {
153 if err.kind() == io::ErrorKind::NotFound {
154 return true;
155 }
156 err.raw_os_error() == Some(ENOENT)
157}
158
159pub fn is_read_only_filesystem(err: &io::Error) -> bool {
165 if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
166 return true;
167 }
168 err.raw_os_error() == Some(EROFS)
169}
170
171pub fn is_cross_device_link(err: &io::Error) -> bool {
178 if err.kind() == io::ErrorKind::CrossesDevices {
179 return true;
180 }
181 err.raw_os_error() == Some(EXDEV)
182}
183
184pub fn temp_path(path: &Path) -> PathBuf {
185 let parent = path.parent().unwrap_or_else(|| Path::new("."));
186 let file_name = path
187 .file_name()
188 .and_then(|s| s.to_str())
189 .filter(|s| !s.is_empty())
190 .unwrap_or("heddle-tmp");
191 let unique = SystemTime::now()
192 .duration_since(UNIX_EPOCH)
193 .map(|d| d.as_nanos())
194 .unwrap_or(0);
195 let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
196 let pid = std::process::id();
197 parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
198}
199
200#[cfg(target_os = "linux")]
209fn kick_writeback(file: &File) {
210 use std::os::unix::io::AsRawFd;
211 const SYNC_FILE_RANGE_WRITE: libc::c_uint = 2;
215 unsafe {
216 libc::sync_file_range(file.as_raw_fd(), 0, 0, SYNC_FILE_RANGE_WRITE);
217 }
218}
219
220#[cfg(not(target_os = "linux"))]
221fn kick_writeback(_file: &File) {}
222
223pub fn stage_temp_files_durable(files: &[(PathBuf, Vec<u8>)]) -> io::Result<()> {
243 let mut handles: Vec<File> = Vec::with_capacity(files.len());
244 for (temp_path, bytes) in files {
245 let mut file = File::create(temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
246 file.write_all(bytes)
247 .map_err(|err| enrich_write_error(temp_path, err))?;
248 kick_writeback(&file);
249 handles.push(file);
250 }
251 for (file, (temp_path, _)) in handles.iter().zip(files) {
254 file.sync_all()
255 .map_err(|err| enrich_write_error(temp_path, err))?;
256 }
257 Ok(())
258}
259
260#[cfg(windows)]
284pub fn sync_directory(_path: &Path) -> io::Result<()> {
285 Ok(())
286}
287
288#[cfg(not(windows))]
289pub fn sync_directory(path: &Path) -> io::Result<()> {
290 let dir = OpenOptions::new().read(true).open(path)?;
291 dir.sync_all()
292}
293
294fn plan_missing_dirs(path: &Path) -> (Vec<PathBuf>, Option<PathBuf>) {
298 let mut missing: Vec<PathBuf> = Vec::new();
301 {
302 let mut cur = path;
303 loop {
304 match fs::metadata(cur) {
305 Ok(meta) if meta.is_dir() => break,
306 Ok(_) => {
307 break;
310 }
311 Err(e) if e.kind() == io::ErrorKind::NotFound => {
312 missing.push(cur.to_path_buf());
313 match cur.parent() {
314 Some(parent) if parent.as_os_str().is_empty() => break,
317 Some(parent) if parent != cur => cur = parent,
319 _ => break,
320 }
321 }
322 Err(_) => break,
325 }
326 }
327 }
328
329 let deepest_existing = missing
330 .last()
331 .and_then(|shallowest| match shallowest.parent() {
332 Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
333 Some(parent) => Some(parent.to_path_buf()),
334 None => None,
335 });
336
337 (missing, deepest_existing)
338}
339
340fn sync_new_dirents(missing: &[PathBuf], deepest_existing: Option<&Path>) -> io::Result<()> {
343 if missing.is_empty() {
344 return Ok(());
345 }
346 for dir in missing {
347 sync_directory(dir)?;
348 }
349 if let Some(existing) = deepest_existing {
352 sync_directory(existing)?;
353 }
354 Ok(())
355}
356
357pub fn create_dir_all_durable(path: &Path) -> io::Result<()> {
375 let (missing, deepest_existing) = plan_missing_dirs(path);
376 fs::create_dir_all(path)?;
377 sync_new_dirents(&missing, deepest_existing.as_deref())
378}
379
380fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
390 enrich_fs_error(path, "writing", err)
391}
392
393pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
422 if is_out_of_space(&err) {
423 let msg = format!(
424 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
425 path.display()
426 );
427 return io::Error::new(
428 io::ErrorKind::StorageFull,
429 EnrichedFsError { msg, source: err },
430 );
431 }
432 if is_directory_not_empty(&err) {
433 let msg = format!(
434 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
435 path.display()
436 );
437 return io::Error::new(
438 io::ErrorKind::DirectoryNotEmpty,
439 EnrichedFsError { msg, source: err },
440 );
441 }
442 if is_read_only_filesystem(&err) {
443 let msg = format!(
444 "filesystem is read-only — `{}` cannot be modified",
445 path.display()
446 );
447 return io::Error::new(
448 io::ErrorKind::ReadOnlyFilesystem,
449 EnrichedFsError { msg, source: err },
450 );
451 }
452 if is_permission_denied(&err) {
453 let msg = format!(
454 "permission denied {op} `{}` — check filesystem permissions",
455 path.display()
456 );
457 return io::Error::new(
458 io::ErrorKind::PermissionDenied,
459 EnrichedFsError { msg, source: err },
460 );
461 }
462 if is_not_found(&err) {
463 let msg = format!("could not find `{}` for {op}", path.display());
464 return io::Error::new(
465 io::ErrorKind::NotFound,
466 EnrichedFsError { msg, source: err },
467 );
468 }
469 if is_cross_device_link(&err) {
470 let msg = format!(
471 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
472 path.display()
473 );
474 return io::Error::new(
475 io::ErrorKind::CrossesDevices,
476 EnrichedFsError { msg, source: err },
477 );
478 }
479 err
480}
481
482pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
487 if is_cross_device_link(&err) {
488 let msg = format!(
489 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
490 src.display(),
491 dst.display()
492 );
493 return io::Error::new(
494 io::ErrorKind::CrossesDevices,
495 EnrichedFsError { msg, source: err },
496 );
497 }
498 enrich_fs_error(dst, "renaming", err)
499}
500
501#[derive(Debug)]
502struct EnrichedFsError {
503 msg: String,
504 source: io::Error,
505}
506
507impl std::fmt::Display for EnrichedFsError {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 f.write_str(&self.msg)
510 }
511}
512
513impl std::error::Error for EnrichedFsError {
514 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
515 Some(&self.source)
516 }
517}
518
519fn write_file_atomic_impl(
520 path: &Path,
521 bytes: &[u8],
522 kind: AtomicWriteKind,
523 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
524) -> io::Result<()> {
525 let parent = path.parent().unwrap_or_else(|| Path::new("."));
526 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
527
528 let tmp = temp_path(path);
529 let inner = (|| -> io::Result<()> {
530 let mut file = kind.open_tmp(&tmp)?;
531 kind.enforce_before_write(&file)?;
532 before_write(&file, &tmp)?;
533 file.write_all(bytes)?;
534 file.sync_all()?;
535 Ok(())
536 })();
537
538 if let Err(err) = inner {
539 let _ = fs::remove_file(&tmp);
543 return Err(enrich_write_error(path, err));
544 }
545
546 fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
547 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
548}
549
550pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
551 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
552}
553
554pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
566 #[cfg(unix)]
567 {
568 use std::os::unix::fs::DirBuilderExt;
569 let (missing, deepest_existing) = plan_missing_dirs(path);
570 let mut builder = fs::DirBuilder::new();
571 builder.recursive(true).mode(0o700);
572 match builder.create(path) {
573 Ok(()) => {}
574 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
575 Err(e) => return Err(e),
576 }
577 sync_new_dirents(&missing, deepest_existing.as_deref())
578 }
579 #[cfg(not(unix))]
580 {
581 create_dir_all_durable(path)
583 }
584}
585
586pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
596 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
597}
598
599fn fsync_file_data(path: &Path) -> io::Result<()> {
626 let file = OpenOptions::new()
627 .read(true)
628 .write(true)
629 .open(path)
630 .map_err(|e| enrich_fs_error(path, "opening", e))?;
631 file.sync_all()
632 .map_err(|e| enrich_fs_error(path, "syncing", e))
633}
634
635pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
636 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
637 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
638
639 fsync_file_data(src)?;
644
645 match fs::rename(src, dst) {
646 Ok(()) => {}
647 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
648 let _ = fs::remove_file(src);
650 }
651 Err(e) if is_cross_device_link(&e) => {
652 publish_file_via_copy_durable(src, dst)?;
653 }
654 Err(e) => return Err(enrich_rename_error(src, dst, e)),
655 }
656
657 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
658}
659
660fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
664 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
665 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
666
667 let tmp = temp_path(dst);
668 let result = (|| -> io::Result<()> {
669 fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
670 fsync_file_data(&tmp)?;
671 fs::rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
672 let _ = fs::remove_file(src);
673 Ok(())
674 })();
675 if result.is_err() {
676 let _ = fs::remove_file(&tmp);
677 }
678 result
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn enospc_io_error() -> io::Error {
686 io::Error::from_raw_os_error(ENOSPC)
687 }
688
689 #[test]
690 fn is_out_of_space_detects_enospc_raw() {
691 assert!(is_out_of_space(&enospc_io_error()));
692 }
693
694 #[test]
695 fn is_out_of_space_detects_storage_full_kind() {
696 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
697 assert!(is_out_of_space(&err));
698 }
699
700 #[test]
701 fn is_out_of_space_detects_write_zero() {
702 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
703 assert!(is_out_of_space(&err));
704 }
705
706 #[test]
707 fn is_out_of_space_rejects_unrelated_errors() {
708 assert!(!is_out_of_space(&io::Error::new(
709 io::ErrorKind::NotFound,
710 "missing"
711 )));
712 assert!(!is_out_of_space(&io::Error::new(
713 io::ErrorKind::PermissionDenied,
714 "nope"
715 )));
716 assert!(!is_out_of_space(&io::Error::other("generic")));
717 }
718
719 #[test]
720 fn is_directory_not_empty_detects_kind() {
721 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
722 assert!(is_directory_not_empty(&err));
723 }
724
725 #[test]
726 fn is_directory_not_empty_detects_raw_codes() {
727 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
728 assert!(
729 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
730 "expected raw OS error {code} to classify as ENOTEMPTY"
731 );
732 }
733 }
734
735 #[test]
736 fn is_directory_not_empty_rejects_unrelated() {
737 assert!(!is_directory_not_empty(&io::Error::new(
738 io::ErrorKind::NotFound,
739 "missing"
740 )));
741 assert!(!is_directory_not_empty(&enospc_io_error()));
742 }
743
744 #[test]
745 fn is_permission_denied_detects_kind_and_raw() {
746 assert!(is_permission_denied(&io::Error::new(
747 io::ErrorKind::PermissionDenied,
748 "nope"
749 )));
750 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
751 }
752
753 #[test]
754 fn is_not_found_detects_kind_and_raw() {
755 assert!(is_not_found(&io::Error::new(
756 io::ErrorKind::NotFound,
757 "missing"
758 )));
759 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
760 }
761
762 #[test]
763 fn is_read_only_filesystem_detects_raw() {
764 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
765 EROFS
766 )));
767 }
768
769 #[test]
770 fn is_cross_device_link_detects_raw() {
771 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
772 }
773
774 #[test]
775 fn enrich_fs_error_passes_through_unclassified() {
776 let path = Path::new("/tmp/example");
777 let original = io::Error::other("weird");
778 let wrapped = enrich_fs_error(path, "writing", original);
779 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
781 assert_eq!(wrapped.to_string(), "weird");
782 }
783
784 #[test]
785 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
786 let path = Path::new("/repo/.heddle/state/abc.bin");
787 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
788
789 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
791 let msg = wrapped.to_string();
793 assert!(
794 msg.contains("out of disk space"),
795 "missing failure name: {msg}"
796 );
797 assert!(
798 msg.contains("/repo/.heddle/state/abc.bin"),
799 "missing path: {msg}"
800 );
801 assert!(
802 msg.contains("free disk space") && msg.contains("re-run"),
803 "missing recovery hint: {msg}"
804 );
805 assert!(
806 msg.contains("working tree is unchanged"),
807 "missing reassurance: {msg}"
808 );
809 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
812 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
813 .expect("source preserved");
814 assert!(src.to_string().to_lowercase().contains("space"));
815 }
816
817 #[test]
818 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
819 let path = Path::new("/repo/web");
820 let wrapped = enrich_fs_error(
821 path,
822 "removing",
823 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
824 );
825 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
826 let msg = wrapped.to_string();
827 assert!(
828 msg.contains("could not remove directory"),
829 "missing action: {msg}"
830 );
831 assert!(msg.contains("/repo/web"), "missing path: {msg}");
832 assert!(
833 msg.contains("heddle-ignored"),
834 "missing heddle-ignored hint: {msg}"
835 );
836 assert!(
837 msg.contains("leaving in place"),
838 "missing reassurance: {msg}"
839 );
840 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
845 let original = src
846 .downcast_ref::<io::Error>()
847 .expect("original io::Error preserved");
848 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
849 }
850
851 #[test]
852 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
853 let path = Path::new("/repo/.heddle/state/index.bin");
854 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
855 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
856 let msg = wrapped.to_string();
857 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
858 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
859 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
860 }
861
862 #[test]
863 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
864 let path = Path::new("/repo/.heddle");
865 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
866 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
867 let msg = wrapped.to_string();
868 assert!(msg.contains("could not find"), "missing action: {msg}");
869 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
870 assert!(msg.contains("for opening"), "missing op: {msg}");
871 }
872
873 #[test]
874 fn enrich_fs_error_wraps_erofs_with_path() {
875 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
876 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
877 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
878 let msg = wrapped.to_string();
879 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
880 assert!(
881 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
882 "msg: {msg}"
883 );
884 assert!(msg.contains("cannot be modified"), "msg: {msg}");
885 }
886
887 #[test]
888 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
889 let src = Path::new("/tmp-mount/.x.tmp-1234");
890 let dst = Path::new("/repo/.heddle/state/index.bin");
891 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
892 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
893 let msg = wrapped.to_string();
894 assert!(
895 msg.contains("cannot rename across filesystems"),
896 "msg: {msg}"
897 );
898 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
899 assert!(
900 msg.contains("/repo/.heddle/state/index.bin"),
901 "missing dst: {msg}"
902 );
903 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
904 }
905
906 #[test]
907 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
908 let src = Path::new("/tmp/.x.tmp");
909 let dst = Path::new("/repo/file");
910 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
911 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
914 let msg = wrapped.to_string();
915 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
916 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
917 }
918
919 #[test]
920 fn enrich_write_error_passes_through_non_enospc_unclassified() {
921 let path = Path::new("/tmp/example");
924 let original = io::Error::other("weird");
925 let wrapped = enrich_write_error(path, original);
926 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
927 assert_eq!(wrapped.to_string(), "weird");
928 }
929
930 #[test]
931 fn write_file_atomic_round_trip() {
932 let dir = tempfile::TempDir::new().unwrap();
933 let target = dir.path().join("nested/under/here/file.bin");
934 write_file_atomic(&target, b"hello").unwrap();
935 assert_eq!(fs::read(&target).unwrap(), b"hello");
936 }
937
938 #[test]
939 fn stage_temp_files_durable_writes_every_file_verbatim() {
940 let dir = tempfile::TempDir::new().unwrap();
944 let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
945 .map(|i| {
946 (
947 dir.path().join(format!("ref-{i}.tmp")),
948 format!("change-id-{i}\n").into_bytes(),
949 )
950 })
951 .collect();
952
953 stage_temp_files_durable(&files).unwrap();
954
955 for (path, bytes) in &files {
956 assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
957 }
958 }
959
960 #[test]
961 fn stage_temp_files_durable_empty_batch_is_ok() {
962 stage_temp_files_durable(&[]).unwrap();
965 }
966
967 #[test]
968 fn stage_temp_files_durable_errors_when_parent_missing() {
969 let dir = tempfile::TempDir::new().unwrap();
973 let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
974 assert!(stage_temp_files_durable(&files).is_err());
975 }
976
977 #[cfg(unix)]
978 #[test]
979 fn create_private_dir_all_sets_0700() {
980 use std::os::unix::fs::PermissionsExt;
981
982 let dir = tempfile::TempDir::new().unwrap();
983 let target = dir.path().join("nested/private");
984 create_private_dir_all(&target).expect("create private dir");
985 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
986 assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
987 let mid_mode = fs::metadata(dir.path().join("nested"))
990 .unwrap()
991 .permissions()
992 .mode()
993 & 0o777;
994 assert_eq!(
995 mid_mode, 0o700,
996 "intermediate private ancestor must be 0700"
997 );
998 create_private_dir_all(&target).expect("idempotent private create");
1000 let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1001 assert_eq!(mode_again, 0o700);
1002 }
1003
1004 #[cfg(unix)]
1005 #[test]
1006 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1007 use std::os::unix::fs::PermissionsExt;
1008
1009 let dir = tempfile::TempDir::new().unwrap();
1010 let target = dir.path().join("nested/secret.txt");
1011 let mut observed_tmp_mode = None;
1012
1013 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1014 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1015 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1016 observed_tmp_mode = Some((fd_mode, path_mode));
1017 Ok(())
1018 })
1019 .unwrap();
1020
1021 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1022 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1023 assert_eq!(final_mode, 0o600);
1024 assert_eq!(fs::read(&target).unwrap(), b"secret");
1025 }
1026
1027 #[test]
1028 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1029 let dir = tempfile::TempDir::new().unwrap();
1030 let target = dir.path().join("secret.txt");
1031 let mut tmp_path = None;
1032
1033 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1034 tmp_path = Some(tmp.to_path_buf());
1035 Err(io::Error::new(
1036 io::ErrorKind::PermissionDenied,
1037 "injected permission failure",
1038 ))
1039 })
1040 .expect_err("permission failure should propagate");
1041
1042 assert!(is_permission_denied(&err), "unexpected error: {err}");
1043 assert!(!target.exists(), "secret write must not publish target");
1044 let tmp = tmp_path.expect("pre-write hook observed temp path");
1045 assert!(!tmp.exists(), "failed secret write should remove temp file");
1046 }
1047
1048 #[test]
1057 fn sync_directory_succeeds_on_writable_tempdir() {
1058 let dir = tempfile::TempDir::new().unwrap();
1059 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1060 }
1061
1062 #[test]
1067 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1068 let dir = tempfile::TempDir::new().unwrap();
1069 let target = dir.path().join("oplog/oplog.bin");
1070 let result = write_file_atomic(&target, b"hello");
1071 if let Err(e) = &result {
1072 assert!(
1073 !is_permission_denied(e),
1074 "write_file_atomic surfaced PermissionDenied on a writable \
1075 tempdir (heddle#105): {e}"
1076 );
1077 }
1078 result.expect("write_file_atomic");
1079 }
1080
1081 #[test]
1082 fn publish_file_durable_renames_and_removes_source() {
1083 let dir = tempfile::TempDir::new().unwrap();
1084 let src = dir.path().join("staged.pack");
1085 let dst = dir.path().join("objects/packs/final.pack");
1086 fs::write(&src, b"pack-bytes").unwrap();
1087
1088 publish_file_durable(&src, &dst).unwrap();
1089
1090 assert!(!src.exists(), "source must be consumed by publish");
1091 assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1092 }
1093
1094 #[test]
1098 fn publish_file_durable_syncs_source_without_permission_deny() {
1099 let dir = tempfile::TempDir::new().unwrap();
1100 let src = dir.path().join("staged.bin");
1101 let dst = dir.path().join("final.bin");
1102 fs::write(&src, b"need-fsync-before-rename").unwrap();
1103 let result = publish_file_durable(&src, &dst);
1104 if let Err(e) = &result {
1105 assert!(
1106 !is_permission_denied(e),
1107 "publish_file_durable PermissionDenied on source fsync: {e}"
1108 );
1109 }
1110 result.expect("publish_file_durable");
1111 assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1112 }
1113
1114 #[test]
1115 fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1116 let dir = tempfile::TempDir::new().unwrap();
1122 let src = dir.path().join("staged.pack");
1123 let dst = dir.path().join("final.pack");
1124 fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1127 fs::write(&src, b"complete-new-pack-bytes").unwrap();
1128
1129 publish_file_via_copy_durable(&src, &dst).unwrap();
1130
1131 assert!(!src.exists(), "source must be removed after copy publish");
1132 assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1133 let leftovers: Vec<_> = fs::read_dir(dir.path())
1135 .unwrap()
1136 .filter_map(|e| e.ok())
1137 .map(|e| e.file_name().to_string_lossy().into_owned())
1138 .filter(|name| name.contains(".tmp-"))
1139 .collect();
1140 assert!(
1141 leftovers.is_empty(),
1142 "durable copy must not leave temp siblings: {leftovers:?}"
1143 );
1144 }
1145
1146 #[test]
1147 fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1148 let dir = tempfile::TempDir::new().unwrap();
1153 let src = dir.path().join("staged.pack");
1154 let dst_dir = dir.path().join("final.pack");
1155 fs::write(&src, b"new-bytes").unwrap();
1156 fs::create_dir(&dst_dir).unwrap();
1160
1161 let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1162 assert!(
1163 err.kind() == io::ErrorKind::AlreadyExists
1164 || err.raw_os_error().is_some()
1165 || is_permission_denied(&err)
1166 || err.kind() == io::ErrorKind::Other
1167 || err.kind() == io::ErrorKind::IsADirectory
1168 || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1169 "unexpected error kind for rename-over-dir: {err:?}"
1170 );
1171 assert!(src.exists(), "failed publish must leave source intact");
1172 assert!(dst_dir.is_dir(), "destination directory must be untouched");
1173 let leftovers: Vec<_> = fs::read_dir(dir.path())
1174 .unwrap()
1175 .filter_map(|e| e.ok())
1176 .map(|e| e.file_name().to_string_lossy().into_owned())
1177 .filter(|name| name.contains(".tmp-"))
1178 .collect();
1179 assert!(
1180 leftovers.is_empty(),
1181 "failed publish must clean temp siblings: {leftovers:?}"
1182 );
1183 }
1184
1185 #[test]
1186 fn publish_file_durable_propagates_non_exdev_rename_failures() {
1187 let dir = tempfile::TempDir::new().unwrap();
1192 let src = dir.path().join("staged.pack");
1193 let dst = dir.path().join("final.pack");
1194 fs::write(&src, b"pack-bytes").unwrap();
1195 fs::create_dir(&dst).unwrap();
1196
1197 let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1198 assert!(
1199 !is_cross_device_link(&err),
1200 "failure must not be misclassified as EXDEV: {err}"
1201 );
1202 assert!(src.exists());
1204 }
1205
1206 #[test]
1210 fn create_dir_all_durable_creates_nested_path() {
1211 let dir = tempfile::TempDir::new().unwrap();
1212 let shard = dir.path().join("blobs/ab");
1215 create_dir_all_durable(&shard).expect("create nested shard path");
1216 assert!(shard.is_dir(), "leaf shard directory must exist");
1217 assert!(
1218 dir.path().join("blobs").is_dir(),
1219 "intermediate grandparent must exist"
1220 );
1221 create_dir_all_durable(&shard).expect("idempotent durable create");
1223 assert!(shard.is_dir());
1224 }
1225
1226 #[test]
1230 fn write_file_atomic_creates_missing_shard_parents() {
1231 let dir = tempfile::TempDir::new().unwrap();
1232 let target = dir.path().join("blobs/ab/object.bin");
1233 write_file_atomic(&target, b"shard-bytes").unwrap();
1234 assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1235 assert!(dir.path().join("blobs/ab").is_dir());
1236 }
1237
1238 #[test]
1240 fn create_dir_all_durable_ok_when_path_already_exists() {
1241 let dir = tempfile::TempDir::new().unwrap();
1242 let nested = dir.path().join("already/there");
1243 fs::create_dir_all(&nested).unwrap();
1244 create_dir_all_durable(&nested).expect("existing dir");
1245 assert!(nested.is_dir());
1246 }
1247}