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 enrich_write_error(path: &Path, err: io::Error) -> io::Error {
304 enrich_fs_error(path, "writing", err)
305}
306
307pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
336 if is_out_of_space(&err) {
337 let msg = format!(
338 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
339 path.display()
340 );
341 return io::Error::new(
342 io::ErrorKind::StorageFull,
343 EnrichedFsError { msg, source: err },
344 );
345 }
346 if is_directory_not_empty(&err) {
347 let msg = format!(
348 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
349 path.display()
350 );
351 return io::Error::new(
352 io::ErrorKind::DirectoryNotEmpty,
353 EnrichedFsError { msg, source: err },
354 );
355 }
356 if is_read_only_filesystem(&err) {
357 let msg = format!(
358 "filesystem is read-only — `{}` cannot be modified",
359 path.display()
360 );
361 return io::Error::new(
362 io::ErrorKind::ReadOnlyFilesystem,
363 EnrichedFsError { msg, source: err },
364 );
365 }
366 if is_permission_denied(&err) {
367 let msg = format!(
368 "permission denied {op} `{}` — check filesystem permissions",
369 path.display()
370 );
371 return io::Error::new(
372 io::ErrorKind::PermissionDenied,
373 EnrichedFsError { msg, source: err },
374 );
375 }
376 if is_not_found(&err) {
377 let msg = format!("could not find `{}` for {op}", path.display());
378 return io::Error::new(
379 io::ErrorKind::NotFound,
380 EnrichedFsError { msg, source: err },
381 );
382 }
383 if is_cross_device_link(&err) {
384 let msg = format!(
385 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
386 path.display()
387 );
388 return io::Error::new(
389 io::ErrorKind::CrossesDevices,
390 EnrichedFsError { msg, source: err },
391 );
392 }
393 err
394}
395
396pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
401 if is_cross_device_link(&err) {
402 let msg = format!(
403 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
404 src.display(),
405 dst.display()
406 );
407 return io::Error::new(
408 io::ErrorKind::CrossesDevices,
409 EnrichedFsError { msg, source: err },
410 );
411 }
412 enrich_fs_error(dst, "renaming", err)
413}
414
415#[derive(Debug)]
416struct EnrichedFsError {
417 msg: String,
418 source: io::Error,
419}
420
421impl std::fmt::Display for EnrichedFsError {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 f.write_str(&self.msg)
424 }
425}
426
427impl std::error::Error for EnrichedFsError {
428 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
429 Some(&self.source)
430 }
431}
432
433fn write_file_atomic_impl(
434 path: &Path,
435 bytes: &[u8],
436 kind: AtomicWriteKind,
437 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
438) -> io::Result<()> {
439 let parent = path.parent().unwrap_or_else(|| Path::new("."));
440 fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
441
442 let tmp = temp_path(path);
443 let inner = (|| -> io::Result<()> {
444 let mut file = kind.open_tmp(&tmp)?;
445 kind.enforce_before_write(&file)?;
446 before_write(&file, &tmp)?;
447 file.write_all(bytes)?;
448 file.sync_all()?;
449 Ok(())
450 })();
451
452 if let Err(err) = inner {
453 let _ = fs::remove_file(&tmp);
457 return Err(enrich_write_error(path, err));
458 }
459
460 fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
461 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
462}
463
464pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
465 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
466}
467
468pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
478 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn enospc_io_error() -> io::Error {
486 io::Error::from_raw_os_error(ENOSPC)
487 }
488
489 #[test]
490 fn is_out_of_space_detects_enospc_raw() {
491 assert!(is_out_of_space(&enospc_io_error()));
492 }
493
494 #[test]
495 fn is_out_of_space_detects_storage_full_kind() {
496 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
497 assert!(is_out_of_space(&err));
498 }
499
500 #[test]
501 fn is_out_of_space_detects_write_zero() {
502 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
503 assert!(is_out_of_space(&err));
504 }
505
506 #[test]
507 fn is_out_of_space_rejects_unrelated_errors() {
508 assert!(!is_out_of_space(&io::Error::new(
509 io::ErrorKind::NotFound,
510 "missing"
511 )));
512 assert!(!is_out_of_space(&io::Error::new(
513 io::ErrorKind::PermissionDenied,
514 "nope"
515 )));
516 assert!(!is_out_of_space(&io::Error::other("generic")));
517 }
518
519 #[test]
520 fn is_directory_not_empty_detects_kind() {
521 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
522 assert!(is_directory_not_empty(&err));
523 }
524
525 #[test]
526 fn is_directory_not_empty_detects_raw_codes() {
527 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
528 assert!(
529 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
530 "expected raw OS error {code} to classify as ENOTEMPTY"
531 );
532 }
533 }
534
535 #[test]
536 fn is_directory_not_empty_rejects_unrelated() {
537 assert!(!is_directory_not_empty(&io::Error::new(
538 io::ErrorKind::NotFound,
539 "missing"
540 )));
541 assert!(!is_directory_not_empty(&enospc_io_error()));
542 }
543
544 #[test]
545 fn is_permission_denied_detects_kind_and_raw() {
546 assert!(is_permission_denied(&io::Error::new(
547 io::ErrorKind::PermissionDenied,
548 "nope"
549 )));
550 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
551 }
552
553 #[test]
554 fn is_not_found_detects_kind_and_raw() {
555 assert!(is_not_found(&io::Error::new(
556 io::ErrorKind::NotFound,
557 "missing"
558 )));
559 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
560 }
561
562 #[test]
563 fn is_read_only_filesystem_detects_raw() {
564 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
565 EROFS
566 )));
567 }
568
569 #[test]
570 fn is_cross_device_link_detects_raw() {
571 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
572 }
573
574 #[test]
575 fn enrich_fs_error_passes_through_unclassified() {
576 let path = Path::new("/tmp/example");
577 let original = io::Error::other("weird");
578 let wrapped = enrich_fs_error(path, "writing", original);
579 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
581 assert_eq!(wrapped.to_string(), "weird");
582 }
583
584 #[test]
585 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
586 let path = Path::new("/repo/.heddle/state/abc.bin");
587 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
588
589 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
591 let msg = wrapped.to_string();
593 assert!(
594 msg.contains("out of disk space"),
595 "missing failure name: {msg}"
596 );
597 assert!(
598 msg.contains("/repo/.heddle/state/abc.bin"),
599 "missing path: {msg}"
600 );
601 assert!(
602 msg.contains("free disk space") && msg.contains("re-run"),
603 "missing recovery hint: {msg}"
604 );
605 assert!(
606 msg.contains("working tree is unchanged"),
607 "missing reassurance: {msg}"
608 );
609 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
612 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
613 .expect("source preserved");
614 assert!(src.to_string().to_lowercase().contains("space"));
615 }
616
617 #[test]
618 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
619 let path = Path::new("/repo/web");
620 let wrapped = enrich_fs_error(
621 path,
622 "removing",
623 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
624 );
625 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
626 let msg = wrapped.to_string();
627 assert!(
628 msg.contains("could not remove directory"),
629 "missing action: {msg}"
630 );
631 assert!(msg.contains("/repo/web"), "missing path: {msg}");
632 assert!(
633 msg.contains("heddle-ignored"),
634 "missing heddle-ignored hint: {msg}"
635 );
636 assert!(
637 msg.contains("leaving in place"),
638 "missing reassurance: {msg}"
639 );
640 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
645 let original = src
646 .downcast_ref::<io::Error>()
647 .expect("original io::Error preserved");
648 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
649 }
650
651 #[test]
652 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
653 let path = Path::new("/repo/.heddle/state/index.bin");
654 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
655 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
656 let msg = wrapped.to_string();
657 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
658 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
659 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
660 }
661
662 #[test]
663 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
664 let path = Path::new("/repo/.heddle");
665 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
666 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
667 let msg = wrapped.to_string();
668 assert!(msg.contains("could not find"), "missing action: {msg}");
669 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
670 assert!(msg.contains("for opening"), "missing op: {msg}");
671 }
672
673 #[test]
674 fn enrich_fs_error_wraps_erofs_with_path() {
675 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
676 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
677 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
678 let msg = wrapped.to_string();
679 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
680 assert!(
681 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
682 "msg: {msg}"
683 );
684 assert!(msg.contains("cannot be modified"), "msg: {msg}");
685 }
686
687 #[test]
688 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
689 let src = Path::new("/tmp-mount/.x.tmp-1234");
690 let dst = Path::new("/repo/.heddle/state/index.bin");
691 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
692 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
693 let msg = wrapped.to_string();
694 assert!(
695 msg.contains("cannot rename across filesystems"),
696 "msg: {msg}"
697 );
698 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
699 assert!(
700 msg.contains("/repo/.heddle/state/index.bin"),
701 "missing dst: {msg}"
702 );
703 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
704 }
705
706 #[test]
707 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
708 let src = Path::new("/tmp/.x.tmp");
709 let dst = Path::new("/repo/file");
710 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
711 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
714 let msg = wrapped.to_string();
715 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
716 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
717 }
718
719 #[test]
720 fn enrich_write_error_passes_through_non_enospc_unclassified() {
721 let path = Path::new("/tmp/example");
724 let original = io::Error::other("weird");
725 let wrapped = enrich_write_error(path, original);
726 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
727 assert_eq!(wrapped.to_string(), "weird");
728 }
729
730 #[test]
731 fn write_file_atomic_round_trip() {
732 let dir = tempfile::TempDir::new().unwrap();
733 let target = dir.path().join("nested/under/here/file.bin");
734 write_file_atomic(&target, b"hello").unwrap();
735 assert_eq!(fs::read(&target).unwrap(), b"hello");
736 }
737
738 #[test]
739 fn stage_temp_files_durable_writes_every_file_verbatim() {
740 let dir = tempfile::TempDir::new().unwrap();
744 let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
745 .map(|i| {
746 (
747 dir.path().join(format!("ref-{i}.tmp")),
748 format!("change-id-{i}\n").into_bytes(),
749 )
750 })
751 .collect();
752
753 stage_temp_files_durable(&files).unwrap();
754
755 for (path, bytes) in &files {
756 assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
757 }
758 }
759
760 #[test]
761 fn stage_temp_files_durable_empty_batch_is_ok() {
762 stage_temp_files_durable(&[]).unwrap();
765 }
766
767 #[test]
768 fn stage_temp_files_durable_errors_when_parent_missing() {
769 let dir = tempfile::TempDir::new().unwrap();
773 let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
774 assert!(stage_temp_files_durable(&files).is_err());
775 }
776
777 #[cfg(unix)]
778 #[test]
779 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
780 use std::os::unix::fs::PermissionsExt;
781
782 let dir = tempfile::TempDir::new().unwrap();
783 let target = dir.path().join("nested/secret.txt");
784 let mut observed_tmp_mode = None;
785
786 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
787 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
788 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
789 observed_tmp_mode = Some((fd_mode, path_mode));
790 Ok(())
791 })
792 .unwrap();
793
794 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
795 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
796 assert_eq!(final_mode, 0o600);
797 assert_eq!(fs::read(&target).unwrap(), b"secret");
798 }
799
800 #[test]
801 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
802 let dir = tempfile::TempDir::new().unwrap();
803 let target = dir.path().join("secret.txt");
804 let mut tmp_path = None;
805
806 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
807 tmp_path = Some(tmp.to_path_buf());
808 Err(io::Error::new(
809 io::ErrorKind::PermissionDenied,
810 "injected permission failure",
811 ))
812 })
813 .expect_err("permission failure should propagate");
814
815 assert!(is_permission_denied(&err), "unexpected error: {err}");
816 assert!(!target.exists(), "secret write must not publish target");
817 let tmp = tmp_path.expect("pre-write hook observed temp path");
818 assert!(!tmp.exists(), "failed secret write should remove temp file");
819 }
820
821 #[test]
830 fn sync_directory_succeeds_on_writable_tempdir() {
831 let dir = tempfile::TempDir::new().unwrap();
832 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
833 }
834
835 #[test]
840 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
841 let dir = tempfile::TempDir::new().unwrap();
842 let target = dir.path().join("oplog/oplog.bin");
843 let result = write_file_atomic(&target, b"hello");
844 if let Err(e) = &result {
845 assert!(
846 !is_permission_denied(e),
847 "write_file_atomic surfaced PermissionDenied on a writable \
848 tempdir (heddle#105): {e}"
849 );
850 }
851 result.expect("write_file_atomic");
852 }
853}