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
519pub struct StagedAtomicWrite {
520 path: PathBuf,
521 parent: PathBuf,
522 tmp: PathBuf,
523 pending: bool,
524}
525
526impl StagedAtomicWrite {
527 pub fn publish(mut self) -> io::Result<()> {
528 fs::rename(&self.tmp, &self.path)
529 .map_err(|error| enrich_rename_error(&self.tmp, &self.path, error))?;
530 self.pending = false;
531 sync_directory(&self.parent)
532 .map_err(|error| enrich_fs_error(&self.parent, "syncing", error))
533 }
534}
535
536impl Drop for StagedAtomicWrite {
537 fn drop(&mut self) {
538 if self.pending {
539 let _ = fs::remove_file(&self.tmp);
540 }
541 }
542}
543
544fn stage_file_atomic_impl(
545 path: &Path,
546 bytes: &[u8],
547 kind: AtomicWriteKind,
548 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
549) -> io::Result<StagedAtomicWrite> {
550 let parent = path.parent().unwrap_or_else(|| Path::new("."));
551 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
552
553 let tmp = temp_path(path);
554 let inner = (|| -> io::Result<()> {
555 let mut file = kind.open_tmp(&tmp)?;
556 kind.enforce_before_write(&file)?;
557 before_write(&file, &tmp)?;
558 file.write_all(bytes)?;
559 file.sync_all()?;
560 Ok(())
561 })();
562
563 if let Err(err) = inner {
564 let _ = fs::remove_file(&tmp);
568 return Err(enrich_write_error(path, err));
569 }
570
571 Ok(StagedAtomicWrite {
572 path: path.to_path_buf(),
573 parent: parent.to_path_buf(),
574 tmp,
575 pending: true,
576 })
577}
578
579fn write_file_atomic_impl(
580 path: &Path,
581 bytes: &[u8],
582 kind: AtomicWriteKind,
583 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
584) -> io::Result<()> {
585 stage_file_atomic_impl(path, bytes, kind, before_write)?.publish()
586}
587
588pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
589 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
590}
591
592pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
604 #[cfg(unix)]
605 {
606 use std::os::unix::fs::DirBuilderExt;
607 let (missing, deepest_existing) = plan_missing_dirs(path);
608 let mut builder = fs::DirBuilder::new();
609 builder.recursive(true).mode(0o700);
610 match builder.create(path) {
611 Ok(()) => {}
612 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
613 Err(e) => return Err(e),
614 }
615 sync_new_dirents(&missing, deepest_existing.as_deref())
616 }
617 #[cfg(not(unix))]
618 {
619 create_dir_all_durable(path)
621 }
622}
623
624pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
634 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
635}
636
637pub fn stage_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<StagedAtomicWrite> {
638 stage_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
639}
640
641fn fsync_file_data(path: &Path) -> io::Result<()> {
668 let file = OpenOptions::new()
669 .read(true)
670 .write(true)
671 .open(path)
672 .map_err(|e| enrich_fs_error(path, "opening", e))?;
673 file.sync_all()
674 .map_err(|e| enrich_fs_error(path, "syncing", e))
675}
676
677pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
678 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
679 create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
680
681 fsync_file_data(src)?;
686
687 match fs::rename(src, dst) {
688 Ok(()) => {}
689 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
690 let _ = fs::remove_file(src);
692 }
693 Err(e) if is_cross_device_link(&e) => {
694 publish_file_via_copy_durable(src, dst)?;
695 }
696 Err(e) => return Err(enrich_rename_error(src, dst, e)),
697 }
698
699 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
700}
701
702fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
706 let parent = dst.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(dst);
710 let result = (|| -> io::Result<()> {
711 fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
712 fsync_file_data(&tmp)?;
713 fs::rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
714 let _ = fs::remove_file(src);
715 Ok(())
716 })();
717 if result.is_err() {
718 let _ = fs::remove_file(&tmp);
719 }
720 result
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 fn enospc_io_error() -> io::Error {
728 io::Error::from_raw_os_error(ENOSPC)
729 }
730
731 #[test]
732 fn is_out_of_space_detects_enospc_raw() {
733 assert!(is_out_of_space(&enospc_io_error()));
734 }
735
736 #[test]
737 fn is_out_of_space_detects_storage_full_kind() {
738 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
739 assert!(is_out_of_space(&err));
740 }
741
742 #[test]
743 fn is_out_of_space_detects_write_zero() {
744 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
745 assert!(is_out_of_space(&err));
746 }
747
748 #[test]
749 fn is_out_of_space_rejects_unrelated_errors() {
750 assert!(!is_out_of_space(&io::Error::new(
751 io::ErrorKind::NotFound,
752 "missing"
753 )));
754 assert!(!is_out_of_space(&io::Error::new(
755 io::ErrorKind::PermissionDenied,
756 "nope"
757 )));
758 assert!(!is_out_of_space(&io::Error::other("generic")));
759 }
760
761 #[test]
762 fn is_directory_not_empty_detects_kind() {
763 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
764 assert!(is_directory_not_empty(&err));
765 }
766
767 #[test]
768 fn is_directory_not_empty_detects_raw_codes() {
769 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
770 assert!(
771 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
772 "expected raw OS error {code} to classify as ENOTEMPTY"
773 );
774 }
775 }
776
777 #[test]
778 fn is_directory_not_empty_rejects_unrelated() {
779 assert!(!is_directory_not_empty(&io::Error::new(
780 io::ErrorKind::NotFound,
781 "missing"
782 )));
783 assert!(!is_directory_not_empty(&enospc_io_error()));
784 }
785
786 #[test]
787 fn is_permission_denied_detects_kind_and_raw() {
788 assert!(is_permission_denied(&io::Error::new(
789 io::ErrorKind::PermissionDenied,
790 "nope"
791 )));
792 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
793 }
794
795 #[test]
796 fn is_not_found_detects_kind_and_raw() {
797 assert!(is_not_found(&io::Error::new(
798 io::ErrorKind::NotFound,
799 "missing"
800 )));
801 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
802 }
803
804 #[test]
805 fn is_read_only_filesystem_detects_raw() {
806 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
807 EROFS
808 )));
809 }
810
811 #[test]
812 fn is_cross_device_link_detects_raw() {
813 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
814 }
815
816 #[test]
817 fn enrich_fs_error_passes_through_unclassified() {
818 let path = Path::new("/tmp/example");
819 let original = io::Error::other("weird");
820 let wrapped = enrich_fs_error(path, "writing", original);
821 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
823 assert_eq!(wrapped.to_string(), "weird");
824 }
825
826 #[test]
827 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
828 let path = Path::new("/repo/.heddle/state/abc.bin");
829 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
830
831 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
833 let msg = wrapped.to_string();
835 assert!(
836 msg.contains("out of disk space"),
837 "missing failure name: {msg}"
838 );
839 assert!(
840 msg.contains("/repo/.heddle/state/abc.bin"),
841 "missing path: {msg}"
842 );
843 assert!(
844 msg.contains("free disk space") && msg.contains("re-run"),
845 "missing recovery hint: {msg}"
846 );
847 assert!(
848 msg.contains("working tree is unchanged"),
849 "missing reassurance: {msg}"
850 );
851 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
854 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
855 .expect("source preserved");
856 assert!(src.to_string().to_lowercase().contains("space"));
857 }
858
859 #[test]
860 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
861 let path = Path::new("/repo/web");
862 let wrapped = enrich_fs_error(
863 path,
864 "removing",
865 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
866 );
867 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
868 let msg = wrapped.to_string();
869 assert!(
870 msg.contains("could not remove directory"),
871 "missing action: {msg}"
872 );
873 assert!(msg.contains("/repo/web"), "missing path: {msg}");
874 assert!(
875 msg.contains("heddle-ignored"),
876 "missing heddle-ignored hint: {msg}"
877 );
878 assert!(
879 msg.contains("leaving in place"),
880 "missing reassurance: {msg}"
881 );
882 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
887 let original = src
888 .downcast_ref::<io::Error>()
889 .expect("original io::Error preserved");
890 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
891 }
892
893 #[test]
894 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
895 let path = Path::new("/repo/.heddle/state/index.bin");
896 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
897 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
898 let msg = wrapped.to_string();
899 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
900 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
901 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
902 }
903
904 #[test]
905 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
906 let path = Path::new("/repo/.heddle");
907 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
908 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
909 let msg = wrapped.to_string();
910 assert!(msg.contains("could not find"), "missing action: {msg}");
911 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
912 assert!(msg.contains("for opening"), "missing op: {msg}");
913 }
914
915 #[test]
916 fn enrich_fs_error_wraps_erofs_with_path() {
917 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
918 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
919 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
920 let msg = wrapped.to_string();
921 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
922 assert!(
923 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
924 "msg: {msg}"
925 );
926 assert!(msg.contains("cannot be modified"), "msg: {msg}");
927 }
928
929 #[test]
930 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
931 let src = Path::new("/tmp-mount/.x.tmp-1234");
932 let dst = Path::new("/repo/.heddle/state/index.bin");
933 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
934 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
935 let msg = wrapped.to_string();
936 assert!(
937 msg.contains("cannot rename across filesystems"),
938 "msg: {msg}"
939 );
940 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
941 assert!(
942 msg.contains("/repo/.heddle/state/index.bin"),
943 "missing dst: {msg}"
944 );
945 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
946 }
947
948 #[test]
949 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
950 let src = Path::new("/tmp/.x.tmp");
951 let dst = Path::new("/repo/file");
952 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
953 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
956 let msg = wrapped.to_string();
957 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
958 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
959 }
960
961 #[test]
962 fn enrich_write_error_passes_through_non_enospc_unclassified() {
963 let path = Path::new("/tmp/example");
966 let original = io::Error::other("weird");
967 let wrapped = enrich_write_error(path, original);
968 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
969 assert_eq!(wrapped.to_string(), "weird");
970 }
971
972 #[test]
973 fn write_file_atomic_round_trip() {
974 let dir = tempfile::TempDir::new().unwrap();
975 let target = dir.path().join("nested/under/here/file.bin");
976 write_file_atomic(&target, b"hello").unwrap();
977 assert_eq!(fs::read(&target).unwrap(), b"hello");
978 }
979
980 #[test]
981 fn stage_temp_files_durable_writes_every_file_verbatim() {
982 let dir = tempfile::TempDir::new().unwrap();
986 let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
987 .map(|i| {
988 (
989 dir.path().join(format!("ref-{i}.tmp")),
990 format!("change-id-{i}\n").into_bytes(),
991 )
992 })
993 .collect();
994
995 stage_temp_files_durable(&files).unwrap();
996
997 for (path, bytes) in &files {
998 assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
999 }
1000 }
1001
1002 #[test]
1003 fn stage_temp_files_durable_empty_batch_is_ok() {
1004 stage_temp_files_durable(&[]).unwrap();
1007 }
1008
1009 #[test]
1010 fn stage_temp_files_durable_errors_when_parent_missing() {
1011 let dir = tempfile::TempDir::new().unwrap();
1015 let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
1016 assert!(stage_temp_files_durable(&files).is_err());
1017 }
1018
1019 #[cfg(unix)]
1020 #[test]
1021 fn create_private_dir_all_sets_0700() {
1022 use std::os::unix::fs::PermissionsExt;
1023
1024 let dir = tempfile::TempDir::new().unwrap();
1025 let target = dir.path().join("nested/private");
1026 create_private_dir_all(&target).expect("create private dir");
1027 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1028 assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
1029 let mid_mode = fs::metadata(dir.path().join("nested"))
1032 .unwrap()
1033 .permissions()
1034 .mode()
1035 & 0o777;
1036 assert_eq!(
1037 mid_mode, 0o700,
1038 "intermediate private ancestor must be 0700"
1039 );
1040 create_private_dir_all(&target).expect("idempotent private create");
1042 let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1043 assert_eq!(mode_again, 0o700);
1044 }
1045
1046 #[cfg(unix)]
1047 #[test]
1048 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1049 use std::os::unix::fs::PermissionsExt;
1050
1051 let dir = tempfile::TempDir::new().unwrap();
1052 let target = dir.path().join("nested/secret.txt");
1053 let mut observed_tmp_mode = None;
1054
1055 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1056 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1057 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1058 observed_tmp_mode = Some((fd_mode, path_mode));
1059 Ok(())
1060 })
1061 .unwrap();
1062
1063 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1064 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1065 assert_eq!(final_mode, 0o600);
1066 assert_eq!(fs::read(&target).unwrap(), b"secret");
1067 }
1068
1069 #[test]
1070 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1071 let dir = tempfile::TempDir::new().unwrap();
1072 let target = dir.path().join("secret.txt");
1073 let mut tmp_path = None;
1074
1075 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1076 tmp_path = Some(tmp.to_path_buf());
1077 Err(io::Error::new(
1078 io::ErrorKind::PermissionDenied,
1079 "injected permission failure",
1080 ))
1081 })
1082 .expect_err("permission failure should propagate");
1083
1084 assert!(is_permission_denied(&err), "unexpected error: {err}");
1085 assert!(!target.exists(), "secret write must not publish target");
1086 let tmp = tmp_path.expect("pre-write hook observed temp path");
1087 assert!(!tmp.exists(), "failed secret write should remove temp file");
1088 }
1089
1090 #[test]
1091 fn staged_secret_is_unpublished_until_publish() {
1092 let dir = tempfile::TempDir::new().unwrap();
1093 let target = dir.path().join("secret.txt");
1094 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1095
1096 assert!(!target.exists());
1097 staged.publish().unwrap();
1098 assert_eq!(fs::read(target).unwrap(), b"secret");
1099 }
1100
1101 #[test]
1102 fn dropping_staged_secret_removes_temporary_file() {
1103 let dir = tempfile::TempDir::new().unwrap();
1104 let target = dir.path().join("secret.txt");
1105 let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1106 drop(staged);
1107
1108 assert!(!target.exists());
1109 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
1110 }
1111
1112 #[test]
1121 fn sync_directory_succeeds_on_writable_tempdir() {
1122 let dir = tempfile::TempDir::new().unwrap();
1123 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1124 }
1125
1126 #[test]
1131 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1132 let dir = tempfile::TempDir::new().unwrap();
1133 let target = dir.path().join("oplog/oplog.bin");
1134 let result = write_file_atomic(&target, b"hello");
1135 if let Err(e) = &result {
1136 assert!(
1137 !is_permission_denied(e),
1138 "write_file_atomic surfaced PermissionDenied on a writable \
1139 tempdir (heddle#105): {e}"
1140 );
1141 }
1142 result.expect("write_file_atomic");
1143 }
1144
1145 #[test]
1146 fn publish_file_durable_renames_and_removes_source() {
1147 let dir = tempfile::TempDir::new().unwrap();
1148 let src = dir.path().join("staged.pack");
1149 let dst = dir.path().join("objects/packs/final.pack");
1150 fs::write(&src, b"pack-bytes").unwrap();
1151
1152 publish_file_durable(&src, &dst).unwrap();
1153
1154 assert!(!src.exists(), "source must be consumed by publish");
1155 assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1156 }
1157
1158 #[test]
1162 fn publish_file_durable_syncs_source_without_permission_deny() {
1163 let dir = tempfile::TempDir::new().unwrap();
1164 let src = dir.path().join("staged.bin");
1165 let dst = dir.path().join("final.bin");
1166 fs::write(&src, b"need-fsync-before-rename").unwrap();
1167 let result = publish_file_durable(&src, &dst);
1168 if let Err(e) = &result {
1169 assert!(
1170 !is_permission_denied(e),
1171 "publish_file_durable PermissionDenied on source fsync: {e}"
1172 );
1173 }
1174 result.expect("publish_file_durable");
1175 assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1176 }
1177
1178 #[test]
1179 fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1180 let dir = tempfile::TempDir::new().unwrap();
1186 let src = dir.path().join("staged.pack");
1187 let dst = dir.path().join("final.pack");
1188 fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1191 fs::write(&src, b"complete-new-pack-bytes").unwrap();
1192
1193 publish_file_via_copy_durable(&src, &dst).unwrap();
1194
1195 assert!(!src.exists(), "source must be removed after copy publish");
1196 assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1197 let leftovers: Vec<_> = fs::read_dir(dir.path())
1199 .unwrap()
1200 .filter_map(|e| e.ok())
1201 .map(|e| e.file_name().to_string_lossy().into_owned())
1202 .filter(|name| name.contains(".tmp-"))
1203 .collect();
1204 assert!(
1205 leftovers.is_empty(),
1206 "durable copy must not leave temp siblings: {leftovers:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1212 let dir = tempfile::TempDir::new().unwrap();
1217 let src = dir.path().join("staged.pack");
1218 let dst_dir = dir.path().join("final.pack");
1219 fs::write(&src, b"new-bytes").unwrap();
1220 fs::create_dir(&dst_dir).unwrap();
1224
1225 let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1226 assert!(
1227 err.kind() == io::ErrorKind::AlreadyExists
1228 || err.raw_os_error().is_some()
1229 || is_permission_denied(&err)
1230 || err.kind() == io::ErrorKind::Other
1231 || err.kind() == io::ErrorKind::IsADirectory
1232 || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1233 "unexpected error kind for rename-over-dir: {err:?}"
1234 );
1235 assert!(src.exists(), "failed publish must leave source intact");
1236 assert!(dst_dir.is_dir(), "destination directory must be untouched");
1237 let leftovers: Vec<_> = fs::read_dir(dir.path())
1238 .unwrap()
1239 .filter_map(|e| e.ok())
1240 .map(|e| e.file_name().to_string_lossy().into_owned())
1241 .filter(|name| name.contains(".tmp-"))
1242 .collect();
1243 assert!(
1244 leftovers.is_empty(),
1245 "failed publish must clean temp siblings: {leftovers:?}"
1246 );
1247 }
1248
1249 #[test]
1250 fn publish_file_durable_propagates_non_exdev_rename_failures() {
1251 let dir = tempfile::TempDir::new().unwrap();
1256 let src = dir.path().join("staged.pack");
1257 let dst = dir.path().join("final.pack");
1258 fs::write(&src, b"pack-bytes").unwrap();
1259 fs::create_dir(&dst).unwrap();
1260
1261 let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1262 assert!(
1263 !is_cross_device_link(&err),
1264 "failure must not be misclassified as EXDEV: {err}"
1265 );
1266 assert!(src.exists());
1268 }
1269
1270 #[test]
1274 fn create_dir_all_durable_creates_nested_path() {
1275 let dir = tempfile::TempDir::new().unwrap();
1276 let shard = dir.path().join("blobs/ab");
1279 create_dir_all_durable(&shard).expect("create nested shard path");
1280 assert!(shard.is_dir(), "leaf shard directory must exist");
1281 assert!(
1282 dir.path().join("blobs").is_dir(),
1283 "intermediate grandparent must exist"
1284 );
1285 create_dir_all_durable(&shard).expect("idempotent durable create");
1287 assert!(shard.is_dir());
1288 }
1289
1290 #[test]
1294 fn write_file_atomic_creates_missing_shard_parents() {
1295 let dir = tempfile::TempDir::new().unwrap();
1296 let target = dir.path().join("blobs/ab/object.bin");
1297 write_file_atomic(&target, b"shard-bytes").unwrap();
1298 assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1299 assert!(dir.path().join("blobs/ab").is_dir());
1300 }
1301
1302 #[test]
1304 fn create_dir_all_durable_ok_when_path_already_exists() {
1305 let dir = tempfile::TempDir::new().unwrap();
1306 let nested = dir.path().join("already/there");
1307 fs::create_dir_all(&nested).unwrap();
1308 create_dir_all_durable(&nested).expect("existing dir");
1309 assert!(nested.is_dir());
1310 }
1311}