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