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(windows)]
224pub fn sync_directory(_path: &Path) -> io::Result<()> {
225 Ok(())
226}
227
228#[cfg(not(windows))]
229pub fn sync_directory(path: &Path) -> io::Result<()> {
230 let dir = OpenOptions::new().read(true).open(path)?;
231 dir.sync_all()
232}
233
234fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
244 enrich_fs_error(path, "writing", err)
245}
246
247pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
276 if is_out_of_space(&err) {
277 let msg = format!(
278 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
279 path.display()
280 );
281 return io::Error::new(
282 io::ErrorKind::StorageFull,
283 EnrichedFsError { msg, source: err },
284 );
285 }
286 if is_directory_not_empty(&err) {
287 let msg = format!(
288 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
289 path.display()
290 );
291 return io::Error::new(
292 io::ErrorKind::DirectoryNotEmpty,
293 EnrichedFsError { msg, source: err },
294 );
295 }
296 if is_read_only_filesystem(&err) {
297 let msg = format!(
298 "filesystem is read-only — `{}` cannot be modified",
299 path.display()
300 );
301 return io::Error::new(
302 io::ErrorKind::ReadOnlyFilesystem,
303 EnrichedFsError { msg, source: err },
304 );
305 }
306 if is_permission_denied(&err) {
307 let msg = format!(
308 "permission denied {op} `{}` — check filesystem permissions",
309 path.display()
310 );
311 return io::Error::new(
312 io::ErrorKind::PermissionDenied,
313 EnrichedFsError { msg, source: err },
314 );
315 }
316 if is_not_found(&err) {
317 let msg = format!("could not find `{}` for {op}", path.display());
318 return io::Error::new(
319 io::ErrorKind::NotFound,
320 EnrichedFsError { msg, source: err },
321 );
322 }
323 if is_cross_device_link(&err) {
324 let msg = format!(
325 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
326 path.display()
327 );
328 return io::Error::new(
329 io::ErrorKind::CrossesDevices,
330 EnrichedFsError { msg, source: err },
331 );
332 }
333 err
334}
335
336pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
341 if is_cross_device_link(&err) {
342 let msg = format!(
343 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
344 src.display(),
345 dst.display()
346 );
347 return io::Error::new(
348 io::ErrorKind::CrossesDevices,
349 EnrichedFsError { msg, source: err },
350 );
351 }
352 enrich_fs_error(dst, "renaming", err)
353}
354
355#[derive(Debug)]
356struct EnrichedFsError {
357 msg: String,
358 source: io::Error,
359}
360
361impl std::fmt::Display for EnrichedFsError {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 f.write_str(&self.msg)
364 }
365}
366
367impl std::error::Error for EnrichedFsError {
368 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
369 Some(&self.source)
370 }
371}
372
373fn write_file_atomic_impl(
374 path: &Path,
375 bytes: &[u8],
376 kind: AtomicWriteKind,
377 before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
378) -> io::Result<()> {
379 let parent = path.parent().unwrap_or_else(|| Path::new("."));
380 fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
381
382 let tmp = temp_path(path);
383 let inner = (|| -> io::Result<()> {
384 let mut file = kind.open_tmp(&tmp)?;
385 kind.enforce_before_write(&file)?;
386 before_write(&file, &tmp)?;
387 file.write_all(bytes)?;
388 file.sync_all()?;
389 Ok(())
390 })();
391
392 if let Err(err) = inner {
393 let _ = fs::remove_file(&tmp);
397 return Err(enrich_write_error(path, err));
398 }
399
400 fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
401 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
402}
403
404pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
405 write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
406}
407
408pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
418 write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn enospc_io_error() -> io::Error {
426 io::Error::from_raw_os_error(ENOSPC)
427 }
428
429 #[test]
430 fn is_out_of_space_detects_enospc_raw() {
431 assert!(is_out_of_space(&enospc_io_error()));
432 }
433
434 #[test]
435 fn is_out_of_space_detects_storage_full_kind() {
436 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
437 assert!(is_out_of_space(&err));
438 }
439
440 #[test]
441 fn is_out_of_space_detects_write_zero() {
442 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
443 assert!(is_out_of_space(&err));
444 }
445
446 #[test]
447 fn is_out_of_space_rejects_unrelated_errors() {
448 assert!(!is_out_of_space(&io::Error::new(
449 io::ErrorKind::NotFound,
450 "missing"
451 )));
452 assert!(!is_out_of_space(&io::Error::new(
453 io::ErrorKind::PermissionDenied,
454 "nope"
455 )));
456 assert!(!is_out_of_space(&io::Error::other("generic")));
457 }
458
459 #[test]
460 fn is_directory_not_empty_detects_kind() {
461 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
462 assert!(is_directory_not_empty(&err));
463 }
464
465 #[test]
466 fn is_directory_not_empty_detects_raw_codes() {
467 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
468 assert!(
469 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
470 "expected raw OS error {code} to classify as ENOTEMPTY"
471 );
472 }
473 }
474
475 #[test]
476 fn is_directory_not_empty_rejects_unrelated() {
477 assert!(!is_directory_not_empty(&io::Error::new(
478 io::ErrorKind::NotFound,
479 "missing"
480 )));
481 assert!(!is_directory_not_empty(&enospc_io_error()));
482 }
483
484 #[test]
485 fn is_permission_denied_detects_kind_and_raw() {
486 assert!(is_permission_denied(&io::Error::new(
487 io::ErrorKind::PermissionDenied,
488 "nope"
489 )));
490 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
491 }
492
493 #[test]
494 fn is_not_found_detects_kind_and_raw() {
495 assert!(is_not_found(&io::Error::new(
496 io::ErrorKind::NotFound,
497 "missing"
498 )));
499 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
500 }
501
502 #[test]
503 fn is_read_only_filesystem_detects_raw() {
504 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
505 EROFS
506 )));
507 }
508
509 #[test]
510 fn is_cross_device_link_detects_raw() {
511 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
512 }
513
514 #[test]
515 fn enrich_fs_error_passes_through_unclassified() {
516 let path = Path::new("/tmp/example");
517 let original = io::Error::other("weird");
518 let wrapped = enrich_fs_error(path, "writing", original);
519 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
521 assert_eq!(wrapped.to_string(), "weird");
522 }
523
524 #[test]
525 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
526 let path = Path::new("/repo/.heddle/state/abc.bin");
527 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
528
529 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
531 let msg = wrapped.to_string();
533 assert!(
534 msg.contains("out of disk space"),
535 "missing failure name: {msg}"
536 );
537 assert!(
538 msg.contains("/repo/.heddle/state/abc.bin"),
539 "missing path: {msg}"
540 );
541 assert!(
542 msg.contains("free disk space") && msg.contains("re-run"),
543 "missing recovery hint: {msg}"
544 );
545 assert!(
546 msg.contains("working tree is unchanged"),
547 "missing reassurance: {msg}"
548 );
549 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
552 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
553 .expect("source preserved");
554 assert!(src.to_string().to_lowercase().contains("space"));
555 }
556
557 #[test]
558 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
559 let path = Path::new("/repo/web");
560 let wrapped = enrich_fs_error(
561 path,
562 "removing",
563 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
564 );
565 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
566 let msg = wrapped.to_string();
567 assert!(
568 msg.contains("could not remove directory"),
569 "missing action: {msg}"
570 );
571 assert!(msg.contains("/repo/web"), "missing path: {msg}");
572 assert!(
573 msg.contains("heddle-ignored"),
574 "missing heddle-ignored hint: {msg}"
575 );
576 assert!(
577 msg.contains("leaving in place"),
578 "missing reassurance: {msg}"
579 );
580 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
585 let original = src
586 .downcast_ref::<io::Error>()
587 .expect("original io::Error preserved");
588 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
589 }
590
591 #[test]
592 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
593 let path = Path::new("/repo/.heddle/state/index.bin");
594 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
595 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
596 let msg = wrapped.to_string();
597 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
598 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
599 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
600 }
601
602 #[test]
603 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
604 let path = Path::new("/repo/.heddle");
605 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
606 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
607 let msg = wrapped.to_string();
608 assert!(msg.contains("could not find"), "missing action: {msg}");
609 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
610 assert!(msg.contains("for opening"), "missing op: {msg}");
611 }
612
613 #[test]
614 fn enrich_fs_error_wraps_erofs_with_path() {
615 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
616 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
617 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
618 let msg = wrapped.to_string();
619 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
620 assert!(
621 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
622 "msg: {msg}"
623 );
624 assert!(msg.contains("cannot be modified"), "msg: {msg}");
625 }
626
627 #[test]
628 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
629 let src = Path::new("/tmp-mount/.x.tmp-1234");
630 let dst = Path::new("/repo/.heddle/state/index.bin");
631 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
632 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
633 let msg = wrapped.to_string();
634 assert!(
635 msg.contains("cannot rename across filesystems"),
636 "msg: {msg}"
637 );
638 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
639 assert!(
640 msg.contains("/repo/.heddle/state/index.bin"),
641 "missing dst: {msg}"
642 );
643 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
644 }
645
646 #[test]
647 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
648 let src = Path::new("/tmp/.x.tmp");
649 let dst = Path::new("/repo/file");
650 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
651 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
654 let msg = wrapped.to_string();
655 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
656 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
657 }
658
659 #[test]
660 fn enrich_write_error_passes_through_non_enospc_unclassified() {
661 let path = Path::new("/tmp/example");
664 let original = io::Error::other("weird");
665 let wrapped = enrich_write_error(path, original);
666 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
667 assert_eq!(wrapped.to_string(), "weird");
668 }
669
670 #[test]
671 fn write_file_atomic_round_trip() {
672 let dir = tempfile::TempDir::new().unwrap();
673 let target = dir.path().join("nested/under/here/file.bin");
674 write_file_atomic(&target, b"hello").unwrap();
675 assert_eq!(fs::read(&target).unwrap(), b"hello");
676 }
677
678 #[cfg(unix)]
679 #[test]
680 fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
681 use std::os::unix::fs::PermissionsExt;
682
683 let dir = tempfile::TempDir::new().unwrap();
684 let target = dir.path().join("nested/secret.txt");
685 let mut observed_tmp_mode = None;
686
687 write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
688 let fd_mode = file.metadata()?.permissions().mode() & 0o777;
689 let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
690 observed_tmp_mode = Some((fd_mode, path_mode));
691 Ok(())
692 })
693 .unwrap();
694
695 assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
696 let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
697 assert_eq!(final_mode, 0o600);
698 assert_eq!(fs::read(&target).unwrap(), b"secret");
699 }
700
701 #[test]
702 fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
703 let dir = tempfile::TempDir::new().unwrap();
704 let target = dir.path().join("secret.txt");
705 let mut tmp_path = None;
706
707 let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
708 tmp_path = Some(tmp.to_path_buf());
709 Err(io::Error::new(
710 io::ErrorKind::PermissionDenied,
711 "injected permission failure",
712 ))
713 })
714 .expect_err("permission failure should propagate");
715
716 assert!(is_permission_denied(&err), "unexpected error: {err}");
717 assert!(!target.exists(), "secret write must not publish target");
718 let tmp = tmp_path.expect("pre-write hook observed temp path");
719 assert!(!tmp.exists(), "failed secret write should remove temp file");
720 }
721
722 #[test]
731 fn sync_directory_succeeds_on_writable_tempdir() {
732 let dir = tempfile::TempDir::new().unwrap();
733 sync_directory(dir.path()).expect("sync_directory on writable tempdir");
734 }
735
736 #[test]
741 fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
742 let dir = tempfile::TempDir::new().unwrap();
743 let target = dir.path().join("oplog/oplog.bin");
744 let result = write_file_atomic(&target, b"hello");
745 if let Err(e) = &result {
746 assert!(
747 !is_permission_denied(e),
748 "write_file_atomic surfaced PermissionDenied on a writable \
749 tempdir (heddle#105): {e}"
750 );
751 }
752 result.expect("write_file_atomic");
753 }
754}