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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum Fidelity {
99 ExactSameFormat,
102 Canonical,
105}
106
107#[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 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 #[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#[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#[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#[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 #[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 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
315fn 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 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#[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
510fn 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 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 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 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 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
616fn 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
632fn 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 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 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
817pub trait IntoDestination {
827 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 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 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 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 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 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 let separated = vec![
1094 artifact("a", b"1"),
1095 artifact("a b", b"2"), artifact("a-x", b"3"), 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 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 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 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 assert!(!base.join("c").exists());
1247 std::fs::remove_dir_all(&base).ok();
1248 }
1249}