Skip to main content

aurum_core/output/
transaction.rs

1//! Shared secure output transaction for STT, cleanup, and TTS (JOE-1644).
2//!
3//! ## Commit protocol
4//!
5//! 1. Resolve destination (reject symlink destinations, directories, empty paths).
6//! 2. Create a unique same-directory temporary file with exclusive create and
7//!    owner-only permissions (0o600 on Unix at create time).
8//! 3. Write payload, flush, and `sync_all` the file — **errors are propagated**.
9//! 4. Publish:
10//!    * **NoClobber (Unix):** `link(tmp, dest)` then `unlink(tmp)`. `link` fails if
11//!      `dest` already exists, so a concurrent creator cannot be overwritten.
12//!    * **NoClobber (Windows):** `rename` only when dest is absent; existence is
13//!      re-checked immediately before rename (residual same-user race documented).
14//!    * **Replace (Unix):** `rename(tmp, dest)` atomically replaces any regular file.
15//!    * **Replace (Windows):** stage existing dest aside, rename temp into place,
16//!      remove backup; on failure restore the previous destination.
17//! 5. Parent-directory `sync_all` errors are propagated (best-effort on platforms
18//!    that reject directory fsync).
19//!
20//! A failure before successful publish leaves the prior destination intact and
21//! removes our temp file. Temp names include a collision-resistant suffix
22//! (PID + wall-clock nanoseconds); exclusive `create_new` is the safety gate
23//! so concurrent writers never share a path.
24//!
25//! ## Durability
26//!
27//! A successful return means: the final path contains the complete requested
28//! bytes, file data was flushed/synced, and the parent directory sync either
29//! succeeded or the platform does not support it (ENOTSUP/EINVAL treated as OK).
30
31use crate::error::{EnvironmentError, Result, UserError};
32use std::fs::{self, File, OpenOptions};
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36/// Overwrite policy for a destination path.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CommitMode {
39    /// Fail if the destination exists as a non-empty regular file, including when
40    /// it appears after preflight (race-safe on Unix via hard-link publish).
41    NoClobber,
42    /// Replace an existing regular file destination.
43    Replace,
44}
45
46/// How to treat destination paths that are symbolic links.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum SymlinkPolicy {
49    /// Reject destinations that are symlinks (default — avoid clobbering via
50    /// unexpected link targets).
51    #[default]
52    Reject,
53}
54
55/// Options for a single output transaction.
56#[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    /// Validate the destination path shape and symlink/directory policy.
86    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    /// Write `bytes` through the full commit protocol.
93    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    /// Write via a callback that receives the exclusive temp file path.
121    ///
122    /// The callback must fully write its content to `tmp`. This path is used when
123    /// the payload is produced by a specialized writer (e.g. WAV).
124    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            // Ensure durable bytes even if the callback only flushed its writer.
135            // Re-open writeable: on Windows, FlushFileBuffers on a read-only handle
136            // can return ERROR_ACCESS_DENIED.
137            {
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
156/// Convenience: commit UTF-8 text with a trailing newline if missing.
157pub 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    // Symlink check uses symlink_metadata so we do not follow the link.
199    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            // Reject non-regular special files (FIFO, device, …) when detectable.
225            #[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                        // Empty file is treated as a placeholder we may replace.
241                        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    // Sanitize stem for hidden temp name (avoid nested path separators).
272    let safe_stem: String = stem
273        .chars()
274        .map(|c| if c == '/' || c == '\\' { '_' } else { c })
275        .collect();
276
277    // Retry on collision (extremely unlikely with unique suffix).
278    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    // Mix PID + nanos for uniqueness without pulling in an RNG crate.
318    format!("{nanos:x}")
319}
320
321/// Publish `tmp` as `dest` according to `mode`.
322///
323/// On success, `tmp` no longer exists (consumed by rename or unlinked after link).
324fn publish(tmp: &Path, dest: &Path, mode: CommitMode, symlink_policy: SymlinkPolicy) -> Result<()> {
325    // Final policy check immediately before publish (covers races after preflight).
326    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
334/// NoClobber: never overwrite a destination that exists at publish time.
335fn publish_noclobber(tmp: &Path, dest: &Path) -> Result<()> {
336    // Empty placeholder may be removed so we can create exclusively.
337    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        // hard_link fails with EEXIST if dest appeared — race-safe vs rename overwrite.
349        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        // Windows: no portable hard_link+EEXIST guarantee for our use; refuse if
373        // dest exists and rename only when absent. Residual TOCTOU vs a concurrent
374        // creator is documented; prefer Unix semantics for strict race safety.
375        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
406/// Replace: strongest platform-native replacement available.
407fn publish_replace(tmp: &Path, dest: &Path) -> Result<()> {
408    #[cfg(unix)]
409    {
410        // Atomic replace of a regular file (or create if absent).
411        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        // Move the existing file aside so we never delete-before-replace.
429        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                // Restore previous destination; leave tmp for the caller to clean.
446                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    // Opening a directory handle for fsync is platform-specific. On Windows,
465    // `File::open` on a directory often returns Access Denied — treat as
466    // unsupported (best-effort durability), not a hard failure (JOE-1644).
467    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                // On Unix, inability to open the parent is unusual — surface it.
478                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            // Some platforms/filesystems reject directory fsync (EINVAL/ENOTSUP).
490            // Windows often cannot fsync directory handles either.
491            #[cfg(windows)]
492            {
493                let _ = e;
494                return Ok(());
495            }
496            #[cfg(not(windows))]
497            {
498                let raw = e.raw_os_error();
499                // EINVAL=22; ENOTSUP=45 (macOS) / 95 (Linux EOPNOTSUPP)
500                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}