1use crate::error::{EnvironmentError, Result, UserError};
32use std::fs::{self, File, OpenOptions};
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CommitMode {
39 NoClobber,
42 Replace,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum SymlinkPolicy {
49 #[default]
52 Reject,
53}
54
55#[derive(Debug, Clone)]
57pub struct OutputTransaction {
58 dest: PathBuf,
59 mode: CommitMode,
60 symlink_policy: SymlinkPolicy,
61}
62
63impl OutputTransaction {
64 pub fn new(dest: impl Into<PathBuf>, mode: CommitMode) -> Self {
65 Self {
66 dest: dest.into(),
67 mode,
68 symlink_policy: SymlinkPolicy::Reject,
69 }
70 }
71
72 pub fn with_symlink_policy(mut self, policy: SymlinkPolicy) -> Self {
73 self.symlink_policy = policy;
74 self
75 }
76
77 pub fn dest(&self) -> &Path {
78 &self.dest
79 }
80
81 pub fn mode(&self) -> CommitMode {
82 self.mode
83 }
84
85 pub fn preflight(&self) -> Result<()> {
87 validate_dest_path(&self.dest)?;
88 check_dest_policy(&self.dest, self.mode, self.symlink_policy)?;
89 Ok(())
90 }
91
92 pub fn commit_bytes(&self, bytes: &[u8]) -> Result<()> {
94 self.preflight()?;
95 ensure_parent_dir(&self.dest)?;
96
97 let tmp = create_exclusive_temp(&self.dest)?;
98 let write_result = (|| -> Result<()> {
99 {
100 let mut file = OpenOptions::new()
101 .write(true)
102 .open(&tmp)
103 .map_err(|e| map_io_write(&self.dest, e))?;
104 file.write_all(bytes)
105 .map_err(|e| map_io_write(&self.dest, e))?;
106 file.flush().map_err(|e| map_io_write(&self.dest, e))?;
107 file.sync_all().map_err(|e| map_io_sync(&self.dest, e))?;
108 }
109 publish(&tmp, &self.dest, self.mode, self.symlink_policy)?;
110 sync_parent_dir(&self.dest)?;
111 Ok(())
112 })();
113
114 if write_result.is_err() {
115 let _ = fs::remove_file(&tmp);
116 }
117 write_result
118 }
119
120 pub fn commit_with<F>(&self, write_tmp: F) -> Result<()>
125 where
126 F: FnOnce(&Path) -> Result<()>,
127 {
128 self.preflight()?;
129 ensure_parent_dir(&self.dest)?;
130
131 let tmp = create_exclusive_temp(&self.dest)?;
132 let write_result = (|| -> Result<()> {
133 write_tmp(&tmp)?;
134 {
138 let file = OpenOptions::new()
139 .write(true)
140 .open(&tmp)
141 .map_err(|e| map_io_write(&self.dest, e))?;
142 file.sync_all().map_err(|e| map_io_sync(&self.dest, e))?;
143 }
144 publish(&tmp, &self.dest, self.mode, self.symlink_policy)?;
145 sync_parent_dir(&self.dest)?;
146 Ok(())
147 })();
148
149 if write_result.is_err() {
150 let _ = fs::remove_file(&tmp);
151 }
152 write_result
153 }
154}
155
156pub fn commit_text(dest: &Path, text: &str, mode: CommitMode) -> Result<()> {
158 let mut body = text.to_string();
159 if !body.ends_with('\n') {
160 body.push('\n');
161 }
162 OutputTransaction::new(dest, mode).commit_bytes(body.as_bytes())
163}
164
165fn validate_dest_path(path: &Path) -> Result<()> {
166 let s = path.as_os_str();
167 if s.is_empty() {
168 return Err(UserError::Other {
169 message: "output path is empty".into(),
170 }
171 .into());
172 }
173 if path.file_name().map(|n| n.is_empty()).unwrap_or(true) {
174 return Err(UserError::Other {
175 message: format!(
176 "output path '{}' must be a file path, not a directory",
177 path.display()
178 ),
179 }
180 .into());
181 }
182 Ok(())
183}
184
185fn ensure_parent_dir(path: &Path) -> Result<()> {
186 if let Some(parent) = path.parent() {
187 if !parent.as_os_str().is_empty() {
188 fs::create_dir_all(parent).map_err(|e| EnvironmentError::DirectoryAccess {
189 path: parent.display().to_string(),
190 reason: e.to_string(),
191 })?;
192 }
193 }
194 Ok(())
195}
196
197fn check_dest_policy(path: &Path, mode: CommitMode, symlink_policy: SymlinkPolicy) -> Result<()> {
198 match fs::symlink_metadata(path) {
200 Ok(meta) => {
201 if meta.file_type().is_symlink() {
202 match symlink_policy {
203 SymlinkPolicy::Reject => {
204 return Err(UserError::Other {
205 message: format!(
206 "output path is a symbolic link (refused): {}\n \
207 Hint: write to a regular file path, or remove the symlink first.",
208 path.display()
209 ),
210 }
211 .into());
212 }
213 }
214 }
215 if meta.is_dir() {
216 return Err(UserError::Other {
217 message: format!(
218 "output path is a directory: {}\n Hint: provide a file path.",
219 path.display()
220 ),
221 }
222 .into());
223 }
224 #[cfg(unix)]
226 {
227 use std::os::unix::fs::FileTypeExt;
228 let ft = meta.file_type();
229 if ft.is_fifo() || ft.is_socket() || ft.is_block_device() || ft.is_char_device() {
230 return Err(UserError::Other {
231 message: format!("output path is not a regular file: {}", path.display()),
232 }
233 .into());
234 }
235 }
236 match mode {
237 CommitMode::Replace => Ok(()),
238 CommitMode::NoClobber => {
239 if meta.len() == 0 {
240 Ok(())
242 } else {
243 Err(UserError::Other {
244 message: format!(
245 "output file already exists: {}\n \
246 Hint: pass --force to overwrite, or choose another path.",
247 path.display()
248 ),
249 }
250 .into())
251 }
252 }
253 }
254 }
255 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
256 Err(e) => Err(EnvironmentError::DirectoryAccess {
257 path: path.display().to_string(),
258 reason: e.to_string(),
259 }
260 .into()),
261 }
262}
263
264fn create_exclusive_temp(dest: &Path) -> Result<PathBuf> {
265 let parent = dest.parent().filter(|p| !p.as_os_str().is_empty());
266 let parent = parent.unwrap_or_else(|| Path::new("."));
267 let stem = dest
268 .file_name()
269 .and_then(|s| s.to_str())
270 .unwrap_or("aurum-out");
271 let safe_stem: String = stem
273 .chars()
274 .map(|c| if c == '/' || c == '\\' { '_' } else { c })
275 .collect();
276
277 for _ in 0..32 {
279 let suffix = random_suffix();
280 let name = format!(".{}.{}-{}.aurum.tmp", safe_stem, std::process::id(), suffix);
281 let tmp = parent.join(name);
282 let mut opts = OpenOptions::new();
283 opts.write(true).create_new(true);
284 #[cfg(unix)]
285 {
286 use std::os::unix::fs::OpenOptionsExt;
287 opts.mode(0o600);
288 }
289 match opts.open(&tmp) {
290 Ok(file) => {
291 drop(file);
292 return Ok(tmp);
293 }
294 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
295 Err(e) => {
296 return Err(EnvironmentError::DirectoryAccess {
297 path: parent.display().to_string(),
298 reason: format!("failed to create exclusive temp file: {e}"),
299 }
300 .into());
301 }
302 }
303 }
304 Err(EnvironmentError::DirectoryAccess {
305 path: parent.display().to_string(),
306 reason: "failed to allocate exclusive temp file after retries".into(),
307 }
308 .into())
309}
310
311fn random_suffix() -> String {
312 use std::time::{SystemTime, UNIX_EPOCH};
313 let nanos = SystemTime::now()
314 .duration_since(UNIX_EPOCH)
315 .map(|d| d.as_nanos())
316 .unwrap_or(0);
317 format!("{nanos:x}")
319}
320
321fn publish(tmp: &Path, dest: &Path, mode: CommitMode, symlink_policy: SymlinkPolicy) -> Result<()> {
325 check_dest_policy(dest, mode, symlink_policy)?;
327
328 match mode {
329 CommitMode::NoClobber => publish_noclobber(tmp, dest),
330 CommitMode::Replace => publish_replace(tmp, dest),
331 }
332}
333
334fn publish_noclobber(tmp: &Path, dest: &Path) -> Result<()> {
336 if let Ok(meta) = fs::symlink_metadata(dest) {
338 if meta.len() == 0 && !meta.file_type().is_symlink() {
339 fs::remove_file(dest).map_err(|e| EnvironmentError::DirectoryAccess {
340 path: dest.display().to_string(),
341 reason: format!("failed to clear empty placeholder: {e}"),
342 })?;
343 }
344 }
345
346 #[cfg(unix)]
347 {
348 match fs::hard_link(tmp, dest) {
350 Ok(()) => {
351 let _ = fs::remove_file(tmp);
352 Ok(())
353 }
354 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(UserError::Other {
355 message: format!(
356 "output file already exists: {}\n \
357 Hint: pass --force to overwrite, or choose another path.",
358 dest.display()
359 ),
360 }
361 .into()),
362 Err(e) => Err(EnvironmentError::DirectoryAccess {
363 path: dest.display().to_string(),
364 reason: format!("NoClobber publish (hard_link) failed: {e}"),
365 }
366 .into()),
367 }
368 }
369
370 #[cfg(not(unix))]
371 {
372 if dest.exists() {
376 return Err(UserError::Other {
377 message: format!(
378 "output file already exists: {}\n \
379 Hint: pass --force to overwrite, or choose another path.",
380 dest.display()
381 ),
382 }
383 .into());
384 }
385 fs::rename(tmp, dest).map_err(|e| {
386 if e.kind() == std::io::ErrorKind::AlreadyExists {
387 UserError::Other {
388 message: format!(
389 "output file already exists: {}\n \
390 Hint: pass --force to overwrite, or choose another path.",
391 dest.display()
392 ),
393 }
394 .into()
395 } else {
396 EnvironmentError::DirectoryAccess {
397 path: dest.display().to_string(),
398 reason: format!("NoClobber publish failed: {e}"),
399 }
400 .into()
401 }
402 })
403 }
404}
405
406fn publish_replace(tmp: &Path, dest: &Path) -> Result<()> {
408 #[cfg(unix)]
409 {
410 fs::rename(tmp, dest).map_err(|e| EnvironmentError::DirectoryAccess {
412 path: dest.display().to_string(),
413 reason: format!("atomic publish failed: {e}"),
414 })?;
415 Ok(())
416 }
417
418 #[cfg(not(unix))]
419 {
420 if !dest.exists() {
421 fs::rename(tmp, dest).map_err(|e| EnvironmentError::DirectoryAccess {
422 path: dest.display().to_string(),
423 reason: format!("atomic publish failed: {e}"),
424 })?;
425 return Ok(());
426 }
427
428 let backup = dest.with_extension(format!(
430 "aurum-bak.{}-{}",
431 std::process::id(),
432 random_suffix()
433 ));
434 fs::rename(dest, &backup).map_err(|e| EnvironmentError::DirectoryAccess {
435 path: dest.display().to_string(),
436 reason: format!("failed to stage existing output for replace: {e}"),
437 })?;
438
439 match fs::rename(tmp, dest) {
440 Ok(()) => {
441 let _ = fs::remove_file(&backup);
442 Ok(())
443 }
444 Err(e) => {
445 let _ = fs::rename(&backup, dest);
447 Err(EnvironmentError::DirectoryAccess {
448 path: dest.display().to_string(),
449 reason: format!("atomic publish failed (previous file restored): {e}"),
450 }
451 .into())
452 }
453 }
454 }
455}
456
457fn sync_parent_dir(dest: &Path) -> Result<()> {
458 let Some(parent) = dest.parent() else {
459 return Ok(());
460 };
461 if parent.as_os_str().is_empty() {
462 return Ok(());
463 }
464 let dir = match File::open(parent) {
468 Ok(d) => d,
469 Err(e) => {
470 #[cfg(windows)]
471 {
472 let _ = e;
473 return Ok(());
474 }
475 #[cfg(not(windows))]
476 {
477 return Err(EnvironmentError::DirectoryAccess {
479 path: parent.display().to_string(),
480 reason: format!("failed to open parent directory for sync: {e}"),
481 }
482 .into());
483 }
484 }
485 };
486 match dir.sync_all() {
487 Ok(()) => Ok(()),
488 Err(e) => {
489 #[cfg(windows)]
492 {
493 let _ = e;
494 return Ok(());
495 }
496 #[cfg(not(windows))]
497 {
498 let raw = e.raw_os_error();
499 if matches!(raw, Some(22) | Some(45) | Some(95)) {
501 return Ok(());
502 }
503 Err(EnvironmentError::DirectoryAccess {
504 path: parent.display().to_string(),
505 reason: format!("parent directory sync failed: {e}"),
506 }
507 .into())
508 }
509 }
510 }
511}
512
513fn map_io_write(dest: &Path, e: std::io::Error) -> crate::error::TranscriptionError {
514 if e.kind() == std::io::ErrorKind::OutOfMemory
515 || e.to_string().to_ascii_lowercase().contains("no space")
516 {
517 return EnvironmentError::DiskSpace {
518 path: dest.display().to_string(),
519 reason: e.to_string(),
520 }
521 .into();
522 }
523 EnvironmentError::DiskSpace {
524 path: dest.display().to_string(),
525 reason: e.to_string(),
526 }
527 .into()
528}
529
530fn map_io_sync(dest: &Path, e: std::io::Error) -> crate::error::TranscriptionError {
531 EnvironmentError::DirectoryAccess {
532 path: dest.display().to_string(),
533 reason: format!("file sync failed (durability): {e}"),
534 }
535 .into()
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use std::io::Read;
542 use std::sync::{Arc, Barrier};
543 use std::thread;
544 use tempfile::tempdir;
545
546 #[test]
547 fn no_clobber_rejects_existing() {
548 let dir = tempdir().unwrap();
549 let path = dir.path().join("out.txt");
550 fs::write(&path, b"old").unwrap();
551 let err = OutputTransaction::new(&path, CommitMode::NoClobber)
552 .commit_bytes(b"new")
553 .unwrap_err();
554 assert_eq!(err.exit_code(), 2);
555 assert_eq!(fs::read(&path).unwrap(), b"old");
556 }
557
558 #[test]
559 fn replace_overwrites() {
560 let dir = tempdir().unwrap();
561 let path = dir.path().join("out.txt");
562 fs::write(&path, b"old").unwrap();
563 OutputTransaction::new(&path, CommitMode::Replace)
564 .commit_bytes(b"new-data")
565 .unwrap();
566 assert_eq!(fs::read(&path).unwrap(), b"new-data");
567 }
568
569 #[test]
570 fn empty_dest_allowed_under_no_clobber() {
571 let dir = tempdir().unwrap();
572 let path = dir.path().join("empty.txt");
573 fs::write(&path, b"").unwrap();
574 OutputTransaction::new(&path, CommitMode::NoClobber)
575 .commit_bytes(b"filled")
576 .unwrap();
577 assert_eq!(fs::read_to_string(&path).unwrap(), "filled");
578 }
579
580 #[test]
581 fn failure_before_publish_keeps_old() {
582 let dir = tempdir().unwrap();
583 let path = dir.path().join("keep.txt");
584 fs::write(&path, b"original").unwrap();
585 let err = OutputTransaction::new(&path, CommitMode::Replace)
586 .commit_with(|_tmp| {
587 Err(EnvironmentError::DiskSpace {
588 path: "inject".into(),
589 reason: "simulated full disk".into(),
590 }
591 .into())
592 })
593 .unwrap_err();
594 assert_eq!(err.exit_code(), 3);
595 assert_eq!(fs::read(&path).unwrap(), b"original");
596 let leftovers: Vec<_> = fs::read_dir(dir.path())
597 .unwrap()
598 .filter_map(|e| e.ok())
599 .filter(|e| e.file_name().to_string_lossy().contains(".aurum.tmp"))
600 .collect();
601 assert!(leftovers.is_empty(), "temp left behind: {leftovers:?}");
602 }
603
604 #[test]
605 fn rejects_directory_dest() {
606 let dir = tempdir().unwrap();
607 let err = OutputTransaction::new(dir.path(), CommitMode::Replace)
608 .commit_bytes(b"x")
609 .unwrap_err();
610 assert_eq!(err.exit_code(), 2);
611 }
612
613 #[test]
614 fn concurrent_temps_are_distinct() {
615 let dir = tempdir().unwrap();
616 let dest = dir.path().join("out.bin");
617 let a = create_exclusive_temp(&dest).unwrap();
618 let b = create_exclusive_temp(&dest).unwrap();
619 assert_ne!(a, b);
620 assert!(a.exists() && b.exists());
621 let _ = fs::remove_file(a);
622 let _ = fs::remove_file(b);
623 }
624
625 #[cfg(unix)]
626 #[test]
627 fn rejects_symlink_destination() {
628 let dir = tempdir().unwrap();
629 let real = dir.path().join("real.txt");
630 fs::write(&real, b"secret").unwrap();
631 let link = dir.path().join("link.txt");
632 std::os::unix::fs::symlink(&real, &link).unwrap();
633 let err = OutputTransaction::new(&link, CommitMode::Replace)
634 .commit_bytes(b"hijack")
635 .unwrap_err();
636 assert_eq!(err.exit_code(), 2);
637 assert_eq!(fs::read(&real).unwrap(), b"secret");
638 }
639
640 #[test]
641 fn commit_text_adds_newline() {
642 let dir = tempdir().unwrap();
643 let path = dir.path().join("t.txt");
644 commit_text(&path, "hello", CommitMode::NoClobber).unwrap();
645 let mut s = String::new();
646 File::open(&path).unwrap().read_to_string(&mut s).unwrap();
647 assert_eq!(s, "hello\n");
648 }
649
650 #[test]
651 fn creates_parent_dirs() {
652 let dir = tempdir().unwrap();
653 let path = dir.path().join("a/b/c.out");
654 OutputTransaction::new(&path, CommitMode::NoClobber)
655 .commit_bytes(b"nested")
656 .unwrap();
657 assert_eq!(fs::read(&path).unwrap(), b"nested");
658 }
659
660 #[cfg(unix)]
661 #[test]
662 fn unix_replace_does_not_unlink_before_rename() {
663 let dir = tempdir().unwrap();
664 let path = dir.path().join("atomic.txt");
665 fs::write(&path, b"original-content").unwrap();
666 OutputTransaction::new(&path, CommitMode::Replace)
667 .commit_bytes(b"replacement")
668 .unwrap();
669 assert_eq!(fs::read_to_string(&path).unwrap(), "replacement");
670 }
671
672 #[cfg(unix)]
673 #[test]
674 fn temp_is_owner_rw_only() {
675 use std::os::unix::fs::PermissionsExt;
676 let dir = tempdir().unwrap();
677 let dest = dir.path().join("perm.out");
678 let tmp = create_exclusive_temp(&dest).unwrap();
679 let mode = fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
680 assert_eq!(mode, 0o600, "expected 0o600, got {mode:o}");
681 let _ = fs::remove_file(tmp);
682 }
683
684 #[cfg(unix)]
685 #[test]
686 fn concurrent_noclobber_exactly_one_wins() {
687 let dir = tempdir().unwrap();
688 let path = Arc::new(dir.path().join("race.txt"));
689 let barrier = Arc::new(Barrier::new(8));
690 let mut handles = vec![];
691 for i in 0..8 {
692 let path = Arc::clone(&path);
693 let barrier = Arc::clone(&barrier);
694 handles.push(thread::spawn(move || {
695 barrier.wait();
696 let body = format!("writer-{i}");
697 OutputTransaction::new(path.as_ref(), CommitMode::NoClobber)
698 .commit_bytes(body.as_bytes())
699 }));
700 }
701 let mut ok = 0;
702 let mut err = 0;
703 for h in handles {
704 match h.join().unwrap() {
705 Ok(()) => ok += 1,
706 Err(_) => err += 1,
707 }
708 }
709 assert_eq!(ok, 1, "exactly one NoClobber writer must succeed");
710 assert_eq!(err, 7);
711 let content = fs::read_to_string(path.as_ref()).unwrap();
712 assert!(
713 content.starts_with("writer-"),
714 "winner bytes incomplete: {content}"
715 );
716 }
717}