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(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 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 #[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#[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#[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#[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 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
256fn 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 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#[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
451fn 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 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 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 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 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
557fn 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
573fn 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 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 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 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 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 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 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 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 let separated = vec![
984 artifact("a", b"1"),
985 artifact("a b", b"2"), artifact("a-x", b"3"), 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 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 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 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 assert!(!base.join("c").exists());
1114 std::fs::remove_dir_all(&base).ok();
1115 }
1116}