Skip to main content

powerio_core/
output.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use crate::validation::{MAX_ARTIFACT_PATH_BYTES, MAX_ARTIFACT_SEGMENT_BYTES};
9use crate::{Diagnostic, Error};
10
11static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
12
13/// Portable relative path of one output artifact.
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct ArtifactPath(Box<str>);
16
17impl ArtifactPath {
18    pub fn new(path: impl Into<String>) -> Result<Self, Error> {
19        let path = path.into();
20        if !valid_artifact_path(&path) {
21            return Err(Error::new(
22                &crate::codes::REQUEST_OUTPUT_INVALID_ARTIFACT_PATH,
23                "artifact paths must be bounded portable relative paths with slash separators",
24            ));
25        }
26        Ok(Self(path.into_boxed_str()))
27    }
28
29    #[must_use]
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33
34    pub fn join(&self, child: &ArtifactPath) -> Result<Self, Error> {
35        Self::new(format!("{}/{}", self.0, child.0))
36    }
37}
38
39impl fmt::Display for ArtifactPath {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter.write_str(&self.0)
42    }
43}
44
45impl From<ArtifactPath> for String {
46    fn from(path: ArtifactPath) -> Self {
47        path.0.into()
48    }
49}
50
51fn valid_artifact_path(path: &str) -> bool {
52    if path.is_empty()
53        || path.len() > MAX_ARTIFACT_PATH_BYTES
54        || path.starts_with('/')
55        || path.contains(['\\', '\0', ':'])
56        || path.chars().any(char::is_control)
57    {
58        return false;
59    }
60    path.split('/').all(|segment| {
61        !segment.is_empty()
62            && segment != "."
63            && segment != ".."
64            && segment.len() <= MAX_ARTIFACT_SEGMENT_BYTES
65    })
66}
67
68#[derive(Debug)]
69enum DestinationKind {
70    Path(PathBuf),
71    Memory { root: ArtifactPath },
72}
73
74/// Physical shape of one completed emission.
75///
76/// Layout is independent of fidelity: both a one-file format and a directory
77/// format can be either an exact same-format echo or a canonical serialization.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum OutputLayout {
80    File,
81    Directory,
82}
83
84impl OutputLayout {
85    #[must_use]
86    const fn from_directory(directory: bool) -> Self {
87        if directory {
88            Self::Directory
89        } else {
90            Self::File
91        }
92    }
93}
94
95/// How an emitted artifact inventory relates to the module supplied to its
96/// writer.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum Fidelity {
99    /// The unchanged module retained this format's source bytes and emitted
100    /// them byte for byte.
101    ExactSameFormat,
102    /// The writer serialized the typed value. Projection losses, when any,
103    /// are reported as diagnostics rather than encoded in this enum.
104    Canonical,
105}
106
107/// Owned output destination for one file, one directory, or memory artifacts.
108#[derive(Debug)]
109pub struct Destination {
110    kind: DestinationKind,
111}
112
113impl Destination {
114    #[must_use]
115    pub fn path(path: impl Into<PathBuf>) -> Self {
116        Self {
117            kind: DestinationKind::Path(path.into()),
118        }
119    }
120
121    pub fn memory(root: impl Into<String>) -> Result<Self, Error> {
122        let root = ArtifactPath::new(root)?;
123        // The root prefixes every returned artifact name, so it meets the
124        // same portability rule validate_inventory applies to writer-supplied
125        // names.
126        if !portable_output_path(root.as_str()) {
127            return Err(Error::new(
128                &crate::codes::REQUEST_OUTPUT_INVALID_ARTIFACT_PATH,
129                format!("output root '{root}' is not portable across platforms"),
130            ));
131        }
132        Ok(Self {
133            kind: DestinationKind::Memory { root },
134        })
135    }
136
137    /// Commit a complete artifact inventory.
138    ///
139    /// Writers pass paths relative to a directory output. For a one file
140    /// output, the destination itself supplies the returned artifact name.
141    #[doc(hidden)]
142    pub fn __commit_artifacts(
143        self,
144        directory: bool,
145        fidelity: Fidelity,
146        mut artifacts: Vec<MemoryArtifact>,
147        diagnostics: Vec<Diagnostic>,
148    ) -> Result<EmitResult, Error> {
149        validate_inventory(directory, &mut artifacts)?;
150        let output = match self.kind {
151            DestinationKind::Memory { root } => {
152                if directory {
153                    for artifact in &mut artifacts {
154                        artifact.name = root.join(&artifact.name)?;
155                    }
156                } else {
157                    artifacts[0].name = root;
158                }
159                EmittedOutput::Memory { artifacts }
160            }
161            DestinationKind::Path(root) => {
162                let paths = commit_path_output(&root, directory, &artifacts)?;
163                EmittedOutput::Path {
164                    root,
165                    artifacts: paths,
166                }
167            }
168        };
169        Ok(EmitResult {
170            output,
171            layout: OutputLayout::from_directory(directory),
172            fidelity,
173            diagnostics,
174        })
175    }
176}
177
178/// One owned memory artifact.
179#[derive(Debug, PartialEq, Eq)]
180pub struct MemoryArtifact {
181    name: ArtifactPath,
182    bytes: Vec<u8>,
183}
184
185impl MemoryArtifact {
186    #[must_use]
187    pub const fn new(name: ArtifactPath, bytes: Vec<u8>) -> Self {
188        Self { name, bytes }
189    }
190
191    #[must_use]
192    pub const fn name(&self) -> &ArtifactPath {
193        &self.name
194    }
195
196    #[must_use]
197    pub fn bytes(&self) -> &[u8] {
198        &self.bytes
199    }
200
201    #[must_use]
202    pub fn into_bytes(self) -> Vec<u8> {
203        self.bytes
204    }
205}
206
207/// Complete inventory of output owned by the caller.
208#[derive(Debug, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum EmittedOutput {
211    Path {
212        root: PathBuf,
213        artifacts: Vec<PathBuf>,
214    },
215    Memory {
216        artifacts: Vec<MemoryArtifact>,
217    },
218}
219
220/// Successful write output plus diagnostics emitted by the writer.
221#[derive(Debug)]
222pub struct EmitResult {
223    output: EmittedOutput,
224    layout: OutputLayout,
225    fidelity: Fidelity,
226    diagnostics: Vec<Diagnostic>,
227}
228
229impl EmitResult {
230    #[must_use]
231    pub const fn output(&self) -> &EmittedOutput {
232        &self.output
233    }
234
235    #[must_use]
236    pub const fn layout(&self) -> OutputLayout {
237        self.layout
238    }
239
240    #[must_use]
241    pub const fn fidelity(&self) -> Fidelity {
242        self.fidelity
243    }
244
245    #[must_use]
246    pub fn diagnostics(&self) -> &[Diagnostic] {
247        &self.diagnostics
248    }
249
250    /// Add diagnostics produced by a facade before or after a family writer.
251    ///
252    /// This is an implementation hook for dispatchers that prepare a value
253    /// for a grid exchange writer. It is not part of the user facing API.
254    #[doc(hidden)]
255    #[must_use]
256    pub fn __with_diagnostics(mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) -> Self {
257        self.diagnostics.extend(diagnostics);
258        self
259    }
260
261    #[must_use]
262    pub fn into_output(self) -> EmittedOutput {
263        self.output
264    }
265}
266
267fn validate_inventory(directory: bool, artifacts: &mut [MemoryArtifact]) -> Result<(), Error> {
268    if artifacts.is_empty() || (!directory && artifacts.len() != 1) {
269        return Err(Error::new(
270            &crate::codes::REQUEST_OUTPUT_INVALID_LAYOUT,
271            if directory {
272                "a directory output must contain at least one artifact"
273            } else {
274                "a one file output must contain exactly one artifact"
275            },
276        ));
277    }
278    artifacts.sort_unstable_by(|left, right| left.name.cmp(&right.name));
279    let mut names = BTreeSet::new();
280    for artifact in artifacts.iter() {
281        if !portable_output_path(artifact.name.as_str()) {
282            return Err(Error::new(
283                &crate::codes::REQUEST_OUTPUT_INVALID_ARTIFACT_PATH,
284                format!(
285                    "output artifact '{}' is not portable across platforms",
286                    artifact.name
287                ),
288            ));
289        }
290        if !names.insert(artifact.name.as_str()) {
291            return Err(Error::new(
292                &crate::codes::REQUEST_OUTPUT_DUPLICATE_ARTIFACT,
293                format!("duplicate output artifact '{}'", artifact.name),
294            ));
295        }
296    }
297    // Every proper `/`-delimited ancestor of every name is checked against the
298    // full name set, so an artifact can never also be a directory of another,
299    // whatever the names sort like.
300    for artifact in artifacts.iter() {
301        let name = artifact.name.as_str();
302        for (offset, _) in name.match_indices('/') {
303            let ancestor = &name[..offset];
304            if names.contains(ancestor) {
305                return Err(Error::new(
306                    &crate::codes::REQUEST_OUTPUT_INVALID_LAYOUT,
307                    format!("output artifact '{ancestor}' is also a directory prefix"),
308                ));
309            }
310        }
311    }
312    Ok(())
313}
314
315/// True when every segment of a committed artifact name designates the same
316/// filesystem entry on every supported platform: no segment ends in a dot or a
317/// space, and no segment's stem is a Windows reserved device name. Source
318/// entry listing deliberately does not apply this predicate; it constrains
319/// only what a destination commits.
320fn portable_output_path(path: &str) -> bool {
321    path.split('/').all(|segment| {
322        if segment.ends_with('.') || segment.ends_with(' ') {
323            return false;
324        }
325        let stem = segment.split('.').next().unwrap_or(segment);
326        !reserved_windows_stem(stem)
327    })
328}
329
330fn reserved_windows_stem(stem: &str) -> bool {
331    if stem.eq_ignore_ascii_case("con")
332        || stem.eq_ignore_ascii_case("prn")
333        || stem.eq_ignore_ascii_case("aux")
334        || stem.eq_ignore_ascii_case("nul")
335    {
336        return true;
337    }
338    let mut characters = stem.chars();
339    let prefix: String = characters.by_ref().take(3).collect();
340    if !(prefix.eq_ignore_ascii_case("com") || prefix.eq_ignore_ascii_case("lpt")) {
341        return false;
342    }
343    matches!(characters.next(), Some(digit) if digit.is_ascii_digit())
344        && characters.next().is_none()
345}
346
347fn commit_path_output(
348    target: &Path,
349    directory: bool,
350    artifacts: &[MemoryArtifact],
351) -> Result<Vec<PathBuf>, Error> {
352    if target.as_os_str().is_empty() {
353        return Err(Error::new(
354            &crate::codes::REQUEST_OUTPUT_INVALID_LAYOUT,
355            "output path cannot be empty",
356        ));
357    }
358    if let Some(parent) = target.parent()
359        && !parent.as_os_str().is_empty()
360    {
361        std::fs::create_dir_all(parent).map_err(|cause| {
362            Error::new(
363                &crate::codes::EMIT_IO_STAGING,
364                format!("cannot create output parent '{}'", parent.display()),
365            )
366            .with_cause(cause)
367        })?;
368    }
369
370    // The commit itself is the collision check: the staged output is moved
371    // onto the target with a rename that refuses an existing entry, so a
372    // target created at any point before the commit is never replaced. This
373    // early inspection only refuses obvious collisions before staging work
374    // begins; correctness does not depend on it.
375    if std::fs::symlink_metadata(target).is_ok() {
376        return Err(collision(target));
377    }
378
379    let mut staging = StagingGuard::create(target, directory)?;
380    let result = if directory {
381        write_directory_artifacts(staging.path(), artifacts)
382    } else {
383        write_single_artifact(
384            staging
385                .file_mut()
386                .expect("one file staging owns its open file"),
387            &artifacts[0],
388        )
389    };
390    if let Err(error) = result {
391        return Err(staging.cleanup_after(error));
392    }
393    staging.commit(target)?;
394
395    Ok(if directory {
396        artifacts
397            .iter()
398            .map(|artifact| target.join(artifact.name.as_str()))
399            .collect()
400    } else {
401        vec![target.to_path_buf()]
402    })
403}
404
405fn write_single_artifact(file: &mut File, artifact: &MemoryArtifact) -> Result<(), Error> {
406    file.write_all(&artifact.bytes).map_err(|cause| {
407        Error::new(
408            &crate::codes::EMIT_IO_WRITE,
409            format!("cannot write output artifact '{}'", artifact.name),
410        )
411        .with_cause(cause)
412    })?;
413    file.sync_all().map_err(|cause| {
414        Error::new(
415            &crate::codes::EMIT_IO_WRITE,
416            format!("cannot flush output artifact '{}'", artifact.name),
417        )
418        .with_cause(cause)
419    })
420}
421
422fn write_directory_artifacts(staging: &Path, artifacts: &[MemoryArtifact]) -> Result<(), Error> {
423    for artifact in artifacts {
424        let path = staging.join(artifact.name.as_str());
425        if let Some(parent) = path.parent() {
426            std::fs::create_dir_all(parent).map_err(|cause| {
427                Error::new(
428                    &crate::codes::EMIT_IO_WRITE,
429                    format!("cannot create directory for artifact '{}'", artifact.name),
430                )
431                .with_cause(cause)
432            })?;
433        }
434        let mut file = OpenOptions::new()
435            .write(true)
436            .create_new(true)
437            .open(&path)
438            .map_err(|cause| {
439                Error::new(
440                    &crate::codes::EMIT_IO_WRITE,
441                    format!("cannot create output artifact '{}'", artifact.name),
442                )
443                .with_cause(cause)
444            })?;
445        write_single_artifact(&mut file, artifact)?;
446    }
447    Ok(())
448}
449
450/// Commit one already staged file onto `target` without replacing an entry
451/// that exists there: the same no-replace rename (and refuse-on-exist
452/// `hard_link` fallback) the destination commit uses. On refusal or failure
453/// the staged file is removed, so a refused write leaves nothing beside the
454/// target. For a streaming writer whose artifact must never be materialized
455/// in memory; everything else commits through [`Destination`].
456#[doc(hidden)]
457pub fn __commit_staged_file(staged: &Path, target: &Path) -> Result<(), Error> {
458    let remove_staged = || {
459        let _ = std::fs::remove_file(staged);
460    };
461    match rename_no_replace(staged, target) {
462        Ok(()) => Ok(()),
463        Err(cause) if commit_collision(&cause) => {
464            remove_staged();
465            Err(collision(target))
466        }
467        Err(cause) if no_replace_unsupported(&cause) => match std::fs::hard_link(staged, target) {
468            Ok(()) => {
469                remove_staged();
470                Ok(())
471            }
472            Err(cause) if commit_collision(&cause) => {
473                remove_staged();
474                Err(collision(target))
475            }
476            Err(cause) => {
477                remove_staged();
478                Err(Error::new(
479                        &crate::codes::EMIT_IO_COMMIT,
480                        format!(
481                            "this filesystem cannot commit '{}' without risking replacement of a concurrently created target",
482                            target.display()
483                        ),
484                    )
485                    .with_cause(cause))
486            }
487        },
488        Err(cause) => {
489            remove_staged();
490            Err(Error::new(
491                &crate::codes::EMIT_IO_COMMIT,
492                format!(
493                    "cannot move complete staging output '{}' into '{}'",
494                    staged.display(),
495                    target.display()
496                ),
497            )
498            .with_cause(cause))
499        }
500    }
501}
502
503fn collision(target: &Path) -> Error {
504    Error::new(
505        &crate::codes::REQUEST_OUTPUT_COLLISION,
506        format!("output target '{}' already exists", target.display()),
507    )
508}
509
510/// Move a complete staged output onto the target without replacing an entry
511/// that exists at commit time.
512///
513/// An ordinary `rename` replaces a regular file at the target, so a target
514/// substituted between the collision inspection and the commit would be
515/// silently overwritten by an output that refused to overwrite anything. The
516/// platform no-replace rename closes that window: `renamex_np(RENAME_EXCL)` on
517/// macOS, `renameat2(RENAME_NOREPLACE)` on Linux, and `MoveFileExW` without
518/// `MOVEFILE_REPLACE_EXISTING` on Windows all fail atomically when the target
519/// entry exists, including when that entry is a dangling symbolic link. On a
520/// filesystem whose rename cannot refuse (old NFS and FAT report the flag as
521/// unsupported), a one file output falls back to `hard_link` plus staging
522/// removal, which is equally refuse-on-exist; a directory output has no such
523/// portable primitive and is refused with a clear error rather than committed
524/// through a race.
525fn rename_no_replace(from: &Path, to: &Path) -> std::io::Result<()> {
526    platform_rename_no_replace(from, to)
527}
528
529#[cfg(target_os = "macos")]
530fn platform_rename_no_replace(from: &Path, to: &Path) -> std::io::Result<()> {
531    let from = path_to_c_string(from)?;
532    let to = path_to_c_string(to)?;
533    // SAFETY: both pointers reference NUL-terminated buffers owned by the
534    // `CString` values above, which outlive the call.
535    let status = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) };
536    if status == 0 {
537        Ok(())
538    } else {
539        Err(std::io::Error::last_os_error())
540    }
541}
542
543#[cfg(target_os = "linux")]
544fn platform_rename_no_replace(from: &Path, to: &Path) -> std::io::Result<()> {
545    let from = path_to_c_string(from)?;
546    let to = path_to_c_string(to)?;
547    // SAFETY: both pointers reference NUL-terminated buffers owned by the
548    // `CString` values above, which outlive the call.
549    let status = unsafe {
550        libc::renameat2(
551            libc::AT_FDCWD,
552            from.as_ptr(),
553            libc::AT_FDCWD,
554            to.as_ptr(),
555            libc::RENAME_NOREPLACE,
556        )
557    };
558    if status == 0 {
559        Ok(())
560    } else {
561        Err(std::io::Error::last_os_error())
562    }
563}
564
565#[cfg(unix)]
566fn path_to_c_string(path: &Path) -> std::io::Result<std::ffi::CString> {
567    use std::os::unix::ffi::OsStrExt;
568    std::ffi::CString::new(path.as_os_str().as_bytes())
569        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))
570}
571
572#[cfg(windows)]
573fn platform_rename_no_replace(from: &Path, to: &Path) -> std::io::Result<()> {
574    use std::os::windows::ffi::OsStrExt;
575
576    #[link(name = "kernel32")]
577    unsafe extern "system" {
578        fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
579    }
580
581    let encode = |path: &Path| -> std::io::Result<Vec<u16>> {
582        let wide: Vec<u16> = path
583            .as_os_str()
584            .encode_wide()
585            .chain(std::iter::once(0))
586            .collect();
587        // An interior zero unit would truncate the name handed to the
588        // platform move; refuse it so the moved name is always the complete
589        // requested target name.
590        if wide[..wide.len() - 1].contains(&0) {
591            return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput));
592        }
593        Ok(wide)
594    };
595    let from = encode(from)?;
596    let to = encode(to)?;
597    // No MOVEFILE_REPLACE_EXISTING: the move fails when the target exists.
598    // SAFETY: both pointers reference NUL-terminated wide buffers owned by the
599    // vectors above, which outlive the call.
600    let status = unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 0) };
601    if status != 0 {
602        Ok(())
603    } else {
604        Err(std::io::Error::last_os_error())
605    }
606}
607
608#[cfg(any(
609    all(unix, not(any(target_os = "macos", target_os = "linux"))),
610    not(any(unix, windows))
611))]
612fn platform_rename_no_replace(_from: &Path, _to: &Path) -> std::io::Result<()> {
613    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
614}
615
616/// True when the filesystem reported the no-replace rename flag itself as
617/// unsupported, rather than reporting a real collision or I/O failure.
618fn no_replace_unsupported(error: &std::io::Error) -> bool {
619    if error.kind() == std::io::ErrorKind::Unsupported {
620        return true;
621    }
622    #[cfg(unix)]
623    if matches!(
624        error.raw_os_error(),
625        Some(libc::EINVAL | libc::ENOSYS | libc::ENOTSUP)
626    ) {
627        return true;
628    }
629    false
630}
631
632/// True when the rename failed because the target entry already exists.
633fn commit_collision(error: &std::io::Error) -> bool {
634    if matches!(
635        error.kind(),
636        std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty
637    ) {
638        return true;
639    }
640    #[cfg(unix)]
641    if matches!(
642        error.raw_os_error(),
643        Some(libc::EEXIST | libc::ENOTEMPTY | libc::EISDIR)
644    ) {
645        return true;
646    }
647    #[cfg(windows)]
648    // ERROR_ALREADY_EXISTS and ERROR_FILE_EXISTS.
649    if matches!(error.raw_os_error(), Some(183 | 80)) {
650        return true;
651    }
652    false
653}
654
655struct StagingGuard {
656    path: PathBuf,
657    directory: bool,
658    file: Option<File>,
659    committed: bool,
660}
661
662impl StagingGuard {
663    fn create(target: &Path, directory: bool) -> Result<Self, Error> {
664        let parent = target.parent().unwrap_or_else(|| Path::new("."));
665        let name = target
666            .file_name()
667            .and_then(|name| name.to_str())
668            .unwrap_or("powerio-output");
669        for _ in 0..32 {
670            let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
671            let path = parent.join(format!(
672                ".{name}.powerio-tmp-{}-{sequence}",
673                std::process::id()
674            ));
675            let created = if directory {
676                std::fs::create_dir(&path).map(|()| None)
677            } else {
678                OpenOptions::new()
679                    .write(true)
680                    .create_new(true)
681                    .open(&path)
682                    .map(Some)
683            };
684            match created {
685                Ok(file) => {
686                    return Ok(Self {
687                        path,
688                        directory,
689                        file,
690                        committed: false,
691                    });
692                }
693                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
694                Err(cause) => {
695                    return Err(Error::new(
696                        &crate::codes::EMIT_IO_STAGING,
697                        "cannot create sibling output staging path",
698                    )
699                    .with_cause(cause));
700                }
701            }
702        }
703        Err(Error::new(
704            &crate::codes::EMIT_IO_STAGING,
705            "could not choose an unused sibling output staging path",
706        ))
707    }
708
709    fn path(&self) -> &Path {
710        &self.path
711    }
712
713    fn file_mut(&mut self) -> Option<&mut File> {
714        self.file.as_mut()
715    }
716
717    fn commit(mut self, target: &Path) -> Result<(), Error> {
718        self.file.take();
719        match rename_no_replace(&self.path, target) {
720            Ok(()) => {
721                self.committed = true;
722                Ok(())
723            }
724            Err(cause) if commit_collision(&cause) => Err(self.cleanup_after(collision(target))),
725            Err(cause) if no_replace_unsupported(&cause) && !self.directory => {
726                // Refuse-on-exist commit for filesystems without a no-replace
727                // rename: `hard_link` fails when the target entry exists, and
728                // the staging entry is removed only after the link succeeds.
729                match std::fs::hard_link(&self.path, target) {
730                    Ok(()) => {
731                        let _ = std::fs::remove_file(&self.path);
732                        self.committed = true;
733                        Ok(())
734                    }
735                    Err(cause) if commit_collision(&cause) => {
736                        Err(self.cleanup_after(collision(target)))
737                    }
738                    Err(cause) => {
739                        let error = Error::new(
740                            &crate::codes::EMIT_IO_COMMIT,
741                            format!(
742                                "this filesystem cannot commit '{}' without risking replacement of a concurrently created target",
743                                target.display()
744                            ),
745                        )
746                        .with_cause(cause);
747                        Err(self.cleanup_after(error))
748                    }
749                }
750            }
751            Err(cause) if no_replace_unsupported(&cause) => {
752                let error = Error::new(
753                    &crate::codes::EMIT_IO_COMMIT,
754                    format!(
755                        "this filesystem has no rename that refuses an existing entry; a directory output at '{}' cannot be committed without risking replacement of a concurrently created target",
756                        target.display()
757                    ),
758                )
759                .with_cause(cause);
760                Err(self.cleanup_after(error))
761            }
762            Err(cause) => {
763                let error = Error::new(
764                    &crate::codes::EMIT_IO_COMMIT,
765                    format!(
766                        "cannot move complete staging output '{}' into '{}'",
767                        self.path.display(),
768                        target.display()
769                    ),
770                )
771                .with_cause(cause);
772                Err(self.cleanup_after(error))
773            }
774        }
775    }
776
777    fn cleanup_after(mut self, original: Error) -> Error {
778        self.file.take();
779        match remove_staging(&self.path, self.directory) {
780            Ok(()) => {
781                self.committed = true;
782                original
783            }
784            Err(cause) => {
785                self.committed = true;
786                Error::new(
787                    &crate::codes::EMIT_IO_CLEANUP,
788                    format!(
789                        "output failed and staging path '{}' could not be removed: {original}",
790                        self.path.display()
791                    ),
792                )
793                .with_cause(cause)
794                .with_diagnostics(original.into_diagnostics())
795            }
796        }
797    }
798}
799
800impl Drop for StagingGuard {
801    fn drop(&mut self) {
802        if !self.committed {
803            self.file.take();
804            let _ = remove_staging(&self.path, self.directory);
805        }
806    }
807}
808
809fn remove_staging(path: &Path, directory: bool) -> std::io::Result<()> {
810    if directory {
811        std::fs::remove_dir_all(path)
812    } else {
813        std::fs::remove_file(path)
814    }
815}
816
817/// One output a write operation can produce.
818///
819/// A string or path names the file or directory to write. A [`Destination`]
820/// passes through, which is how a memory destination and its artifact root
821/// name reach the same operations as a file name.
822///
823/// A caller's own output type reaches the same operations by implementing
824/// it. It carries this one method and gains no further required method:
825/// options belong to the operation, not to the output.
826pub trait IntoDestination {
827    /// Resolve the output.
828    ///
829    /// # Errors
830    /// The output cannot be named.
831    fn into_destination(self) -> Result<Destination, Error>;
832}
833
834impl IntoDestination for Destination {
835    fn into_destination(self) -> Result<Destination, Error> {
836        Ok(self)
837    }
838}
839
840macro_rules! into_destination_by_name {
841    ($($output:ty),* $(,)?) => {
842        $(
843            impl IntoDestination for $output {
844                fn into_destination(self) -> Result<Destination, Error> {
845                    Ok(Destination::path(PathBuf::from(self)))
846                }
847            }
848        )*
849    };
850}
851
852into_destination_by_name!(&str, &String, String, &Path, &PathBuf, PathBuf);
853
854#[cfg(test)]
855mod tests {
856    use std::time::{SystemTime, UNIX_EPOCH};
857
858    use super::*;
859
860    fn test_root(name: &str) -> PathBuf {
861        let nonce = SystemTime::now()
862            .duration_since(UNIX_EPOCH)
863            .unwrap()
864            .as_nanos();
865        std::env::temp_dir().join(format!(
866            "powerio-core-output-{name}-{}-{nonce}",
867            std::process::id()
868        ))
869    }
870
871    fn artifact(name: &str, bytes: &[u8]) -> MemoryArtifact {
872        MemoryArtifact::new(ArtifactPath::new(name).unwrap(), bytes.to_vec())
873    }
874
875    #[test]
876    fn artifact_paths_reject_traversal_and_platform_spelling() {
877        for path in [
878            "",
879            "/root",
880            "../escape",
881            "a/../b",
882            "a/./b",
883            "a//b",
884            "a\\b",
885            "C:drive",
886            "nul\0byte",
887        ] {
888            assert!(ArtifactPath::new(path).is_err(), "{path:?}");
889        }
890        assert!(ArtifactPath::new("a".repeat(MAX_ARTIFACT_SEGMENT_BYTES + 1)).is_err());
891        assert!(ArtifactPath::new("case/buses.csv").is_ok());
892    }
893
894    #[test]
895    fn memory_output_owns_sorted_complete_artifacts() {
896        let result = Destination::memory("case")
897            .unwrap()
898            .__commit_artifacts(
899                true,
900                Fidelity::Canonical,
901                vec![
902                    artifact("lines.csv", b"lines"),
903                    artifact("buses.csv", b"buses"),
904                ],
905                Vec::new(),
906            )
907            .unwrap();
908        let EmittedOutput::Memory { artifacts } = result.into_output() else {
909            panic!("memory output")
910        };
911        assert_eq!(
912            artifacts
913                .iter()
914                .map(|artifact| artifact.name().as_str())
915                .collect::<Vec<_>>(),
916            ["case/buses.csv", "case/lines.csv"]
917        );
918        assert_eq!(artifacts[0].bytes(), b"buses");
919    }
920
921    #[test]
922    fn an_existing_target_is_refused_and_never_replaced() {
923        let path = test_root("refused");
924        let target = path.join("case.m");
925        commit_path_output(&target, false, &[artifact("case.m", b"one")]).unwrap();
926
927        // A second write finds the name taken and refuses, and the first
928        // output is still there byte for byte.
929        let error = commit_path_output(&target, false, &[artifact("case.m", b"two")])
930            .expect_err("an existing target is a collision");
931        assert_eq!(error.category(), crate::ErrorCategory::Request);
932        assert_eq!(std::fs::read(&target).unwrap(), b"one");
933
934        // A nonempty directory at the target is also a refusal, and its
935        // contents survive.
936        let directory = path.join("as-a-directory");
937        std::fs::create_dir_all(&directory).unwrap();
938        let blocked = directory.join("out");
939        std::fs::create_dir(&blocked).unwrap();
940        std::fs::write(blocked.join("keep"), b"kept").unwrap();
941        assert!(commit_path_output(&blocked, true, &[artifact("a.csv", b"a")]).is_err());
942        assert_eq!(std::fs::read(blocked.join("keep")).unwrap(), b"kept");
943
944        std::fs::remove_dir_all(&path).ok();
945    }
946
947    #[test]
948    fn the_commit_refuses_a_target_created_after_staging_began() {
949        // A target that appears between the collision inspection and the
950        // commit must not be replaced. The commit primitive itself is the
951        // guarantee: it fails on an existing entry instead of renaming over
952        // it, for a file, a directory, and an empty directory target.
953        let path = test_root("late-target");
954        std::fs::create_dir_all(&path).unwrap();
955
956        let staged = path.join("staged.m");
957        std::fs::write(&staged, b"staged").unwrap();
958        let target = path.join("case.m");
959        std::fs::write(&target, b"foreign").unwrap();
960        let error = rename_no_replace(&staged, &target).expect_err("existing file target");
961        assert!(commit_collision(&error), "{error:?}");
962        assert_eq!(std::fs::read(&target).unwrap(), b"foreign");
963        assert_eq!(std::fs::read(&staged).unwrap(), b"staged");
964
965        // An empty directory created at the target after staging is likewise
966        // never replaced; a plain rename would have swapped a staged
967        // directory straight over it.
968        let staged_dir = path.join("staged-dir");
969        std::fs::create_dir(&staged_dir).unwrap();
970        let target_dir = path.join("out-dir");
971        std::fs::create_dir(&target_dir).unwrap();
972        let error = rename_no_replace(&staged_dir, &target_dir).expect_err("existing dir target");
973        assert!(commit_collision(&error), "{error:?}");
974        assert!(target_dir.is_dir());
975        assert!(staged_dir.is_dir());
976
977        // With no target entry the same primitive commits.
978        let fresh = path.join("fresh.m");
979        rename_no_replace(&staged, &fresh).unwrap();
980        assert_eq!(std::fs::read(&fresh).unwrap(), b"staged");
981
982        std::fs::remove_dir_all(&path).ok();
983    }
984
985    #[test]
986    fn path_output_refuses_collisions_and_does_not_overwrite() {
987        let path = test_root("collision");
988        std::fs::write(&path, b"existing").unwrap();
989        let error = Destination::path(&path)
990            .__commit_artifacts(
991                false,
992                Fidelity::Canonical,
993                vec![artifact("case.m", b"new")],
994                Vec::new(),
995            )
996            .unwrap_err();
997        assert_eq!(error.category(), crate::ErrorCategory::Request);
998        assert_eq!(std::fs::read(&path).unwrap(), b"existing");
999        std::fs::remove_file(path).unwrap();
1000    }
1001
1002    #[test]
1003    fn complete_directory_output_is_committed_at_once() {
1004        let path = test_root("directory");
1005        let result = Destination::path(&path)
1006            .__commit_artifacts(
1007                true,
1008                Fidelity::Canonical,
1009                vec![
1010                    artifact("buses.csv", b"buses"),
1011                    artifact("nested/lines.csv", b"lines"),
1012                ],
1013                Vec::new(),
1014            )
1015            .unwrap();
1016        let EmittedOutput::Path { root, artifacts } = result.into_output() else {
1017            panic!("path output")
1018        };
1019        assert_eq!(root, path);
1020        assert_eq!(
1021            artifacts,
1022            [path.join("buses.csv"), path.join("nested/lines.csv")]
1023        );
1024        assert_eq!(
1025            std::fs::read(path.join("nested/lines.csv")).unwrap(),
1026            b"lines"
1027        );
1028        std::fs::remove_dir_all(path).unwrap();
1029    }
1030
1031    #[test]
1032    fn abandoned_staging_output_is_removed() {
1033        let target = test_root("cleanup");
1034        let staging_path = {
1035            let staging = StagingGuard::create(&target, true).unwrap();
1036            let path = staging.path().to_path_buf();
1037            std::fs::write(path.join("partial"), b"partial").unwrap();
1038            path
1039        };
1040        assert!(!staging_path.exists());
1041        assert!(!target.exists());
1042    }
1043
1044    #[cfg(unix)]
1045    #[test]
1046    fn a_symlink_at_the_target_is_a_collision() {
1047        use std::os::unix::fs::symlink;
1048
1049        let target = test_root("symlink");
1050        let missing = target.with_extension("missing");
1051        symlink(&missing, &target).unwrap();
1052        let error = Destination::path(&target)
1053            .__commit_artifacts(
1054                false,
1055                Fidelity::Canonical,
1056                vec![artifact("case.m", b"new")],
1057                Vec::new(),
1058            )
1059            .unwrap_err();
1060        assert_eq!(error.category(), crate::ErrorCategory::Request);
1061        assert!(
1062            std::fs::symlink_metadata(&target)
1063                .unwrap()
1064                .file_type()
1065                .is_symlink()
1066        );
1067        std::fs::remove_file(target).unwrap();
1068    }
1069
1070    #[test]
1071    fn duplicate_and_prefix_collisions_are_rejected_before_writing() {
1072        let duplicate = Destination::memory("case").unwrap().__commit_artifacts(
1073            true,
1074            Fidelity::Canonical,
1075            vec![artifact("a", b"1"), artifact("a", b"2")],
1076            Vec::new(),
1077        );
1078        assert!(duplicate.is_err());
1079        let prefix = Destination::memory("case").unwrap().__commit_artifacts(
1080            true,
1081            Fidelity::Canonical,
1082            vec![artifact("a", b"1"), artifact("a/b", b"2")],
1083            Vec::new(),
1084        );
1085        assert!(prefix.is_err());
1086    }
1087
1088    #[test]
1089    fn a_prefix_collision_is_refused_whatever_sorts_between() {
1090        // Names whose first differing byte sorts below `/` separate the
1091        // ancestor from its child in sorted order, so an adjacent-pair scan
1092        // would miss the conflict; the ancestor check must not.
1093        let separated = vec![
1094            artifact("a", b"1"),
1095            artifact("a b", b"2"), // space (0x20) < '/'
1096            artifact("a-x", b"3"), // '-' (0x2D) < '/'
1097            artifact("a.csv", b"4"),
1098            artifact("a/b", b"5"),
1099        ];
1100        let memory = Destination::memory("case").unwrap().__commit_artifacts(
1101            true,
1102            Fidelity::Canonical,
1103            separated
1104                .iter()
1105                .map(|a| artifact(a.name().as_str(), a.bytes()))
1106                .collect(),
1107            Vec::new(),
1108        );
1109        let error = memory.expect_err("the ancestor conflict is refused");
1110        assert_eq!(error.category(), crate::ErrorCategory::Request);
1111
1112        let target = test_root("prefix-separated");
1113        let path = Destination::path(&target).__commit_artifacts(
1114            true,
1115            Fidelity::Canonical,
1116            separated,
1117            Vec::new(),
1118        );
1119        let error = path.expect_err("the path destination refuses identically");
1120        assert_eq!(error.category(), crate::ErrorCategory::Request);
1121        // Nothing was created and no staging entry was left beside the target.
1122        assert!(!target.exists());
1123        let parent = target.parent().unwrap();
1124        let residue: Vec<String> = std::fs::read_dir(parent)
1125            .unwrap()
1126            .filter_map(std::result::Result::ok)
1127            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1128            .filter(|name| name.contains(target.file_name().unwrap().to_str().unwrap()))
1129            .collect();
1130        assert!(residue.is_empty(), "{residue:?}");
1131    }
1132
1133    #[test]
1134    fn reserved_and_nonportable_spellings_are_refused_at_commit() {
1135        let refused = [
1136            "con",
1137            "CON",
1138            "con.txt",
1139            "PRN.csv",
1140            "aux",
1141            "AUX.dss",
1142            "nul.m",
1143            "com1",
1144            "COM9.raw",
1145            "lpt0",
1146            "LPT5.csv",
1147            "trailing.",
1148            "trailing ",
1149            "nested/aux.csv",
1150            "aux/nested.csv",
1151        ];
1152        for name in refused {
1153            let memory = Destination::memory("case").unwrap().__commit_artifacts(
1154                true,
1155                Fidelity::Canonical,
1156                vec![artifact(name, b"x"), artifact("keep.csv", b"y")],
1157                Vec::new(),
1158            );
1159            let error = memory.expect_err(name);
1160            assert_eq!(error.category(), crate::ErrorCategory::Request, "{name}");
1161
1162            let target = test_root("reserved");
1163            let path = Destination::path(&target).__commit_artifacts(
1164                true,
1165                Fidelity::Canonical,
1166                vec![artifact(name, b"x")],
1167                Vec::new(),
1168            );
1169            assert!(path.is_err(), "{name}");
1170            assert!(!target.exists(), "{name}");
1171        }
1172        // Ordinary inventories still commit, reserved-looking stems included
1173        // only when they are not reserved (`config`, `auxiliary`).
1174        let accepted = Destination::memory("case").unwrap().__commit_artifacts(
1175            true,
1176            Fidelity::Canonical,
1177            vec![
1178                artifact("case.dss", b"a"),
1179                artifact("buscoords.csv", b"b"),
1180                artifact("network.csv", b"c"),
1181                artifact("nested/lines.csv", b"d"),
1182                artifact("config.json", b"e"),
1183                artifact("auxiliary.csv", b"f"),
1184                artifact("com10.csv", b"g"),
1185            ],
1186            Vec::new(),
1187        );
1188        assert!(accepted.is_ok());
1189    }
1190
1191    #[test]
1192    fn a_memory_root_meets_the_same_portability_rule_as_artifact_names() {
1193        for root in ["aux", "AUX.case", "trailing.", "trailing ", "nested/nul"] {
1194            let refused = Destination::memory(root);
1195            let error = refused.expect_err(root);
1196            assert_eq!(error.category(), crate::ErrorCategory::Request, "{root}");
1197        }
1198        // An ordinary root still commits and still prefixes every name, in
1199        // both the one file and the directory form.
1200        let one = Destination::memory("case.m")
1201            .unwrap()
1202            .__commit_artifacts(
1203                false,
1204                Fidelity::ExactSameFormat,
1205                vec![artifact("case.m", b"x")],
1206                Vec::new(),
1207            )
1208            .unwrap();
1209        assert_eq!(one.layout(), OutputLayout::File);
1210        assert_eq!(one.fidelity(), Fidelity::ExactSameFormat);
1211        let EmittedOutput::Memory { artifacts } = one.into_output() else {
1212            panic!("memory output")
1213        };
1214        assert_eq!(artifacts[0].name().as_str(), "case.m");
1215        let directory = Destination::memory("case")
1216            .unwrap()
1217            .__commit_artifacts(
1218                true,
1219                Fidelity::Canonical,
1220                vec![artifact("buses.csv", b"x")],
1221                Vec::new(),
1222            )
1223            .unwrap();
1224        assert_eq!(directory.layout(), OutputLayout::Directory);
1225        assert_eq!(directory.fidelity(), Fidelity::Canonical);
1226        let EmittedOutput::Memory { artifacts } = directory.into_output() else {
1227            panic!("memory output")
1228        };
1229        assert_eq!(artifacts[0].name().as_str(), "case/buses.csv");
1230    }
1231
1232    #[cfg(windows)]
1233    #[test]
1234    fn a_windows_commit_refuses_an_interior_nul_in_the_target_name() {
1235        use std::os::windows::ffi::OsStringExt;
1236
1237        let base = test_root("wide-nul");
1238        std::fs::create_dir_all(&base).unwrap();
1239        let staged = base.join("staged.m");
1240        std::fs::write(&staged, b"staged").unwrap();
1241        let hostile: std::path::PathBuf =
1242            std::ffi::OsString::from_wide(&[b'c' as u16, 0, b'x' as u16]).into();
1243        let error = rename_no_replace(&staged, &base.join(hostile)).unwrap_err();
1244        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1245        // No file appeared at the truncated name.
1246        assert!(!base.join("c").exists());
1247        std::fs::remove_dir_all(&base).ok();
1248    }
1249}