1use std::collections::BTreeMap;
4use std::fmt;
5use std::io::{self, Write};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use cap_std::ambient_authority;
11use cap_std::fs::{Dir, OpenOptions};
12
13use crate::pack_archive::StagingResidueStatus;
14use crate::{
15 CommitCertainty, CompilationArtifactWriteEntry, CompilationArtifactWriteProgress,
16 CompilationArtifactWriteReceipt, CompilationResult, CompilationStatus, PackExtractionPlan,
17 PackExtractionWriteEntry, PackExtractionWriteProgress, PackExtractionWriteReceipt,
18 WriteKeyOutcome,
19};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum FilesystemMergePolicy {
24 WriteNewTree,
26 MergeCreateOnly,
28 MergeReplaceExactFiles,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum FilesystemWritePhase {
35 Policy,
36 Preflight,
37 DirectoryCreate,
38 StagingCreate,
39 StagingWrite,
40 StagingFlush,
41 Commit,
42 StagingCleanup,
43 Complete,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum FilesystemDestinationEntryKind {
49 File,
50 Directory,
51 Symlink,
52 Other,
53}
54
55impl fmt::Display for FilesystemDestinationEntryKind {
56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57 formatter.write_str(match self {
58 Self::File => "file",
59 Self::Directory => "directory",
60 Self::Symlink => "symbolic link",
61 Self::Other => "unsupported entry",
62 })
63 }
64}
65
66#[derive(Clone, Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum FilesystemWritePreflightIssue {
70 #[error("planned path {relative_path:?} is not a canonical relative filesystem path")]
71 InvalidRelativePath { relative_path: PathBuf },
72 #[error("planned paths {first_path:?} and {second_path:?} alias on this platform")]
73 PathAlias {
74 first_path: PathBuf,
75 second_path: PathBuf,
76 },
77 #[error("planned path {relative_path:?} contains reserved component {component:?}")]
78 ReservedName {
79 relative_path: PathBuf,
80 component: String,
81 },
82 #[error("component {component:?} in planned path {relative_path:?} exceeds the platform limit")]
83 ComponentTooLong {
84 relative_path: PathBuf,
85 component: String,
86 },
87 #[error("planned destination path {relative_path:?} exceeds the platform path limit")]
88 PathTooLong { relative_path: PathBuf },
89 #[error("destination path {path:?} contains reserved component {component:?}")]
90 DestinationReservedName { path: PathBuf, component: String },
91 #[error("component {component:?} in destination path {path:?} exceeds the platform limit")]
92 DestinationComponentTooLong { path: PathBuf, component: String },
93 #[error("destination path {path:?} exceeds the platform path limit")]
94 DestinationPathTooLong { path: PathBuf },
95 #[error("planned target {relative_path:?} already exists")]
96 ExistingTarget { relative_path: PathBuf },
97 #[error("planned target {relative_path:?} is an existing {kind}, not a regular file")]
98 ConflictingTarget {
99 relative_path: PathBuf,
100 kind: FilesystemDestinationEntryKind,
101 },
102 #[error("ancestor {ancestor:?} of planned target {relative_path:?} is a {kind}")]
103 ConflictingAncestor {
104 relative_path: PathBuf,
105 ancestor: PathBuf,
106 kind: FilesystemDestinationEntryKind,
107 },
108 #[error("destination root {path:?} already exists")]
109 ExistingDestinationRoot { path: PathBuf },
110 #[error("destination root {path:?} is a {kind}")]
111 ConflictingDestinationRoot {
112 path: PathBuf,
113 kind: FilesystemDestinationEntryKind,
114 },
115 #[error("could not inspect destination path {path:?}: {source}")]
116 InspectionFailed {
117 path: PathBuf,
118 #[source]
119 source: Arc<io::Error>,
120 },
121}
122
123impl FilesystemWritePreflightIssue {
124 fn sort_key(&self) -> (PathBuf, u8, PathBuf, String) {
125 match self {
126 Self::InvalidRelativePath { relative_path } => {
127 (relative_path.clone(), 0, PathBuf::new(), String::new())
128 }
129 Self::PathAlias {
130 first_path,
131 second_path,
132 } => (first_path.clone(), 1, second_path.clone(), String::new()),
133 Self::ReservedName {
134 relative_path,
135 component,
136 } => (relative_path.clone(), 2, PathBuf::new(), component.clone()),
137 Self::ComponentTooLong {
138 relative_path,
139 component,
140 } => (relative_path.clone(), 3, PathBuf::new(), component.clone()),
141 Self::PathTooLong { relative_path } => {
142 (relative_path.clone(), 4, PathBuf::new(), String::new())
143 }
144 Self::DestinationReservedName { path, component } => {
145 (PathBuf::new(), 0, path.clone(), component.clone())
146 }
147 Self::DestinationComponentTooLong { path, component } => {
148 (PathBuf::new(), 1, path.clone(), component.clone())
149 }
150 Self::DestinationPathTooLong { path } => {
151 (PathBuf::new(), 2, path.clone(), String::new())
152 }
153 Self::ExistingTarget { relative_path } => {
154 (relative_path.clone(), 5, PathBuf::new(), String::new())
155 }
156 Self::ConflictingTarget { relative_path, .. } => {
157 (relative_path.clone(), 6, PathBuf::new(), String::new())
158 }
159 Self::ConflictingAncestor {
160 relative_path,
161 ancestor,
162 ..
163 } => (relative_path.clone(), 7, ancestor.clone(), String::new()),
164 Self::ExistingDestinationRoot { path } => {
165 (PathBuf::new(), 8, path.clone(), String::new())
166 }
167 Self::ConflictingDestinationRoot { path, .. } => {
168 (PathBuf::new(), 9, path.clone(), String::new())
169 }
170 Self::InspectionFailed { path, .. } => {
171 (PathBuf::new(), 10, path.clone(), String::new())
172 }
173 }
174 }
175}
176
177#[derive(Debug, thiserror::Error)]
179#[non_exhaustive]
180pub enum FilesystemWriteErrorCause {
181 #[error("filesystem write preflight rejected the destination")]
182 Preflight,
183 #[error("the platform cannot guarantee {0:?}")]
184 UnsupportedPolicy(FilesystemMergePolicy),
185 #[error(transparent)]
186 Io(#[from] io::Error),
187}
188
189macro_rules! workflow_error {
190 ($progress:ident, $error:ident) => {
191 #[derive(Debug, thiserror::Error)]
192 #[error(
193 "filesystem write to {destination:?} failed during {phase:?} with {commit_certainty:?} certainty: {source}"
194 )]
195 pub struct $error {
196 destination: Box<Path>,
197 policy: FilesystemMergePolicy,
198 phase: FilesystemWritePhase,
199 failed_target: Option<Box<Path>>,
200 staging_residue: Option<Box<Path>>,
201 staging_residue_status: StagingResidueStatus,
202 commit_certainty: CommitCertainty,
203 progress: Box<$progress>,
204 preflight_issues: Option<Box<[FilesystemWritePreflightIssue]>>,
205 #[source]
206 source: Box<FilesystemWriteErrorCause>,
207 }
208
209 impl $error {
210 pub fn destination(&self) -> &Path {
211 &self.destination
212 }
213
214 pub const fn policy(&self) -> FilesystemMergePolicy {
215 self.policy
216 }
217
218 pub const fn phase(&self) -> FilesystemWritePhase {
219 self.phase
220 }
221
222 pub fn failed_target(&self) -> Option<&Path> {
223 self.failed_target.as_deref()
224 }
225
226 pub fn staging_residue(&self) -> Option<&Path> {
227 self.staging_residue.as_deref()
228 }
229
230 pub const fn staging_residue_status(&self) -> StagingResidueStatus {
231 self.staging_residue_status
232 }
233
234 pub const fn commit_certainty(&self) -> CommitCertainty {
235 self.commit_certainty
236 }
237
238 pub fn progress(&self) -> &$progress {
239 self.progress.as_ref()
240 }
241
242 pub fn preflight_issues(&self) -> Option<&[FilesystemWritePreflightIssue]> {
243 self.preflight_issues.as_deref()
244 }
245
246 pub fn cause(&self) -> &FilesystemWriteErrorCause {
247 self.source.as_ref()
248 }
249 }
250 };
251}
252
253workflow_error!(PackExtractionWriteProgress, PackExtractionWriteError);
254workflow_error!(
255 CompilationArtifactWriteProgress,
256 CompilationArtifactWriteError
257);
258
259struct PlannedFile<'a> {
260 relative_path: &'a Path,
261 bytes: &'a [u8],
262}
263
264#[cfg(fuzzing)]
265#[doc(hidden)]
266#[derive(Clone, Copy, Debug)]
267pub struct FilesystemWriteFaultProbe {
268 pub maximum_write: usize,
269 pub write_fault_file: Option<usize>,
270 pub write_fault_after: usize,
271 pub flush_fault_file: Option<usize>,
272 pub commit_fault_file: Option<usize>,
273 pub ancestor_symlink_race_file: Option<usize>,
274 pub new_tree_commit_unsupported: bool,
275 pub new_tree_policy_unsupported: bool,
276 pub tree_staging_open_fault: bool,
277 pub tree_staging_cleanup_fault: bool,
278}
279
280#[derive(Clone, Copy)]
281struct WriteFaults {
282 maximum_write: usize,
283 write_fault_file: Option<usize>,
284 write_fault_after: usize,
285 flush_fault_file: Option<usize>,
286 commit_fault_file: Option<usize>,
287 #[cfg(unix)]
292 ancestor_symlink_race_file: Option<usize>,
293 new_tree_commit_unsupported: bool,
294 new_tree_policy_unsupported: bool,
295 tree_staging_open_fault: bool,
296 tree_staging_cleanup_fault: bool,
297}
298
299impl Default for WriteFaults {
300 fn default() -> Self {
301 Self {
302 maximum_write: usize::MAX,
303 write_fault_file: None,
304 write_fault_after: usize::MAX,
305 flush_fault_file: None,
306 commit_fault_file: None,
307 #[cfg(unix)]
308 ancestor_symlink_race_file: None,
309 new_tree_commit_unsupported: false,
310 new_tree_policy_unsupported: false,
311 tree_staging_open_fault: false,
312 tree_staging_cleanup_fault: false,
313 }
314 }
315}
316
317#[cfg(fuzzing)]
318impl From<FilesystemWriteFaultProbe> for WriteFaults {
319 fn from(probe: FilesystemWriteFaultProbe) -> Self {
320 Self {
321 maximum_write: probe.maximum_write,
322 write_fault_file: probe.write_fault_file,
323 write_fault_after: probe.write_fault_after,
324 flush_fault_file: probe.flush_fault_file,
325 commit_fault_file: probe.commit_fault_file,
326 #[cfg(unix)]
327 ancestor_symlink_race_file: probe.ancestor_symlink_race_file,
328 new_tree_commit_unsupported: probe.new_tree_commit_unsupported,
329 new_tree_policy_unsupported: probe.new_tree_policy_unsupported,
330 tree_staging_open_fault: probe.tree_staging_open_fault,
331 tree_staging_cleanup_fault: probe.tree_staging_cleanup_fault,
332 }
333 }
334}
335
336#[derive(Clone, Copy, Debug, Eq, PartialEq)]
337enum CommitScope {
338 PlannedFile(usize),
339 DestinationRoot,
340}
341
342#[derive(Debug)]
343struct CoreReceipt {
344 completed: Vec<usize>,
345}
346
347struct CoreError {
348 phase: FilesystemWritePhase,
349 failed_target: Option<PathBuf>,
350 staging_residue: Option<PathBuf>,
351 staging_residue_status: Option<StagingResidueStatus>,
352 commit_certainty: CommitCertainty,
353 completed: Vec<usize>,
354 preflight_issues: Option<Vec<FilesystemWritePreflightIssue>>,
355 source: FilesystemWriteErrorCause,
356}
357
358pub fn write_pack_extraction_plan_to_filesystem(
367 plan: &PackExtractionPlan,
368 destination: impl AsRef<Path>,
369 policy: FilesystemMergePolicy,
370) -> Result<PackExtractionWriteReceipt, PackExtractionWriteError> {
371 let destination = destination.as_ref();
372 let files = plan
373 .entries()
374 .iter()
375 .map(|entry| PlannedFile {
376 relative_path: Path::new(entry.relative_path()),
377 bytes: entry.bytes(),
378 })
379 .collect::<Vec<_>>();
380 match write_files(&files, destination, policy) {
381 Ok(receipt) => Ok(PackExtractionWriteReceipt::new(
382 *plan.pack_identity(),
383 pack_extraction_progress(&files, receipt.completed, policy),
384 )),
385 Err(error) => Err(pack_extraction_error(&files, destination, policy, error)),
386 }
387}
388
389#[cfg(fuzzing)]
390#[doc(hidden)]
391pub fn write_pack_extraction_plan_to_filesystem_with_fault_probe(
392 plan: &PackExtractionPlan,
393 destination: impl AsRef<Path>,
394 policy: FilesystemMergePolicy,
395 probe: FilesystemWriteFaultProbe,
396) -> Result<PackExtractionWriteReceipt, PackExtractionWriteError> {
397 let destination = destination.as_ref();
398 let files = plan
399 .entries()
400 .iter()
401 .map(|entry| PlannedFile {
402 relative_path: Path::new(entry.relative_path()),
403 bytes: entry.bytes(),
404 })
405 .collect::<Vec<_>>();
406 match write_files_with_faults(&files, destination, policy, probe.into(), |_, _, _| {}) {
407 Ok(receipt) => Ok(PackExtractionWriteReceipt::new(
408 *plan.pack_identity(),
409 pack_extraction_progress(&files, receipt.completed, policy),
410 )),
411 Err(error) => Err(pack_extraction_error(&files, destination, policy, error)),
412 }
413}
414
415#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
417#[non_exhaustive]
418pub enum CompilationArtifactWriteIssue {
419 #[error("a rejected Compilation Result cannot be written")]
420 RejectedCompilationResult,
421 #[error(
422 "the Compilation Result has {artifact_count} artifact(s), but {path_count} output path(s) were supplied"
423 )]
424 PathCountMismatch {
425 artifact_count: usize,
426 path_count: usize,
427 },
428 #[error("caller-selected artifact paths {first_path:?} and {second_path:?} conflict")]
429 PathConflict {
430 first_path: PathBuf,
431 second_path: PathBuf,
432 },
433}
434
435#[derive(Debug)]
436enum CompilationArtifactPathWriteErrorKind {
437 Issues(Box<[CompilationArtifactWriteIssue]>),
438 Write(CompilationArtifactWriteError),
439}
440
441#[derive(Debug)]
443pub struct CompilationArtifactPathWriteError {
444 kind: CompilationArtifactPathWriteErrorKind,
445}
446
447impl CompilationArtifactPathWriteError {
448 pub fn issues(&self) -> Option<&[CompilationArtifactWriteIssue]> {
450 match &self.kind {
451 CompilationArtifactPathWriteErrorKind::Issues(issues) => Some(issues),
452 _ => None,
453 }
454 }
455
456 pub fn write_error(&self) -> Option<&CompilationArtifactWriteError> {
458 match &self.kind {
459 CompilationArtifactPathWriteErrorKind::Write(error) => Some(error),
460 _ => None,
461 }
462 }
463}
464
465impl fmt::Display for CompilationArtifactPathWriteError {
466 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467 match &self.kind {
468 CompilationArtifactPathWriteErrorKind::Issues(issues) => {
469 if let [issue] = issues.as_ref() {
470 return issue.fmt(formatter);
471 }
472 write!(
473 formatter,
474 "Compilation Output Artifact write rejected with {} issue(s)",
475 issues.len()
476 )?;
477 for issue in issues {
478 write!(formatter, ": {issue}")?;
479 }
480 Ok(())
481 }
482 CompilationArtifactPathWriteErrorKind::Write(error) => error.fmt(formatter),
483 }
484 }
485}
486
487impl std::error::Error for CompilationArtifactPathWriteError {
488 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
489 self.write_error()
490 .map(|error| error as &(dyn std::error::Error + 'static))
491 }
492}
493
494impl From<CompilationArtifactWriteError> for CompilationArtifactPathWriteError {
495 fn from(error: CompilationArtifactWriteError) -> Self {
496 Self {
497 kind: CompilationArtifactPathWriteErrorKind::Write(error),
498 }
499 }
500}
501
502#[derive(Debug, thiserror::Error)]
504#[non_exhaustive]
505pub enum FilesystemWritePathError {
506 #[error("cannot resolve the current directory: {source}")]
507 CurrentDirectory {
508 #[source]
509 source: io::Error,
510 },
511 #[error("cannot resolve output directory `{path}`: {source}")]
512 OutputDirectory {
513 path: PathBuf,
514 #[source]
515 source: io::Error,
516 },
517 #[error("output path `{path}` does not name a file")]
518 OutputPathDoesNotNameFile { path: PathBuf },
519 #[error("output paths do not share a filesystem root")]
520 NoSharedRoot,
521}
522
523pub fn resolve_filesystem_write_paths(
528 targets: &[PathBuf],
529) -> Result<(PathBuf, Vec<PathBuf>), FilesystemWritePathError> {
530 if targets.is_empty() {
531 let current = Path::new(".")
532 .canonicalize()
533 .map_err(|source| FilesystemWritePathError::CurrentDirectory { source })?;
534 return Ok((current, Vec::new()));
535 }
536 let resolved_targets = targets
537 .iter()
538 .map(|target| {
539 let parent = target.parent().unwrap_or_else(|| Path::new("."));
540 let parent = if parent.as_os_str().is_empty() {
541 Path::new(".")
542 } else {
543 parent
544 };
545 let parent = parent.canonicalize().map_err(|source| {
546 FilesystemWritePathError::OutputDirectory {
547 path: parent.to_owned(),
548 source,
549 }
550 })?;
551 let file_name = target.file_name().ok_or_else(|| {
552 FilesystemWritePathError::OutputPathDoesNotNameFile {
553 path: target.to_owned(),
554 }
555 })?;
556 Ok(parent.join(file_name))
557 })
558 .collect::<Result<Vec<PathBuf>, FilesystemWritePathError>>()?;
559 let mut destination = resolved_targets[0]
560 .parent()
561 .expect("a resolved output file has a parent")
562 .to_owned();
563 while resolved_targets
564 .iter()
565 .any(|target| !target.starts_with(&destination))
566 {
567 if !destination.pop() {
568 return Err(FilesystemWritePathError::NoSharedRoot);
569 }
570 }
571 let relative_paths = resolved_targets
572 .iter()
573 .map(|target| {
574 target
575 .strip_prefix(&destination)
576 .expect("the selected destination is a common path prefix")
577 .to_owned()
578 })
579 .collect();
580 Ok((destination, relative_paths))
581}
582
583pub fn write_compilation_artifacts_to_filesystem_paths(
590 result: &CompilationResult,
591 destination: impl AsRef<Path>,
592 relative_paths: &[PathBuf],
593 policy: FilesystemMergePolicy,
594) -> Result<CompilationArtifactWriteReceipt, CompilationArtifactPathWriteError> {
595 let destination = destination.as_ref();
596 let mut issues = Vec::new();
597 if result.status() != CompilationStatus::Succeeded {
598 issues.push(CompilationArtifactWriteIssue::RejectedCompilationResult);
599 }
600 if result.artifacts().len() != relative_paths.len() {
601 issues.push(CompilationArtifactWriteIssue::PathCountMismatch {
602 artifact_count: result.artifacts().len(),
603 path_count: relative_paths.len(),
604 });
605 }
606 let mut ordered_paths = relative_paths.iter().collect::<Vec<_>>();
607 ordered_paths.sort();
608 for (index, first) in ordered_paths.iter().enumerate() {
609 for second in &ordered_paths[index + 1..] {
610 if second.starts_with(first) {
611 issues.push(CompilationArtifactWriteIssue::PathConflict {
612 first_path: (*first).to_owned(),
613 second_path: (*second).to_owned(),
614 });
615 }
616 }
617 }
618 if !issues.is_empty() {
619 return Err(CompilationArtifactPathWriteError {
620 kind: CompilationArtifactPathWriteErrorKind::Issues(issues.into_boxed_slice()),
621 });
622 }
623 let files = result
624 .artifacts()
625 .iter()
626 .zip(relative_paths)
627 .map(|(artifact, relative_path)| PlannedFile {
628 relative_path,
629 bytes: artifact.bytes(),
630 })
631 .collect::<Vec<_>>();
632 write_compilation_artifact_files(result, &files, destination, policy).map_err(Into::into)
633}
634
635fn write_compilation_artifact_files(
636 result: &CompilationResult,
637 files: &[PlannedFile<'_>],
638 destination: &Path,
639 policy: FilesystemMergePolicy,
640) -> Result<CompilationArtifactWriteReceipt, CompilationArtifactWriteError> {
641 match write_files(files, destination, policy) {
642 Ok(receipt) => Ok(CompilationArtifactWriteReceipt::new(
643 result.result_identity(),
644 compilation_artifact_progress(receipt.completed, policy),
645 )),
646 Err(error) => Err(compilation_artifact_error(destination, policy, error)),
647 }
648}
649
650fn pack_extraction_error(
651 files: &[PlannedFile<'_>],
652 destination: &Path,
653 policy: FilesystemMergePolicy,
654 error: CoreError,
655) -> PackExtractionWriteError {
656 let staging_residue_status = error
657 .staging_residue_status
658 .unwrap_or_else(|| residue_status(error.staging_residue.as_deref()));
659 let staging_residue = retained_residue(error.staging_residue, staging_residue_status)
660 .map(PathBuf::into_boxed_path);
661 PackExtractionWriteError {
662 destination: destination.into(),
663 policy,
664 phase: error.phase,
665 failed_target: error.failed_target.map(PathBuf::into_boxed_path),
666 staging_residue: staging_residue.clone(),
667 staging_residue_status,
668 commit_certainty: error.commit_certainty,
669 progress: Box::new(pack_extraction_progress(files, error.completed, policy)),
670 preflight_issues: error.preflight_issues.map(Vec::into_boxed_slice),
671 source: Box::new(error.source),
672 }
673}
674
675fn compilation_artifact_error(
676 destination: &Path,
677 policy: FilesystemMergePolicy,
678 error: CoreError,
679) -> CompilationArtifactWriteError {
680 let staging_residue_status = error
681 .staging_residue_status
682 .unwrap_or_else(|| residue_status(error.staging_residue.as_deref()));
683 let staging_residue = retained_residue(error.staging_residue, staging_residue_status)
684 .map(PathBuf::into_boxed_path);
685 CompilationArtifactWriteError {
686 destination: destination.into(),
687 policy,
688 phase: error.phase,
689 failed_target: error.failed_target.map(PathBuf::into_boxed_path),
690 staging_residue: staging_residue.clone(),
691 staging_residue_status,
692 commit_certainty: error.commit_certainty,
693 progress: Box::new(compilation_artifact_progress(error.completed, policy)),
694 preflight_issues: error.preflight_issues.map(Vec::into_boxed_slice),
695 source: Box::new(error.source),
696 }
697}
698
699fn pack_extraction_progress(
700 files: &[PlannedFile<'_>],
701 completed: Vec<usize>,
702 policy: FilesystemMergePolicy,
703) -> PackExtractionWriteProgress {
704 let outcome = filesystem_outcome(policy);
705 PackExtractionWriteProgress::from_completed(
706 completed
707 .into_iter()
708 .map(|index| {
709 PackExtractionWriteEntry::new(
710 files[index]
711 .relative_path
712 .to_str()
713 .expect("Pack Extraction paths are UTF-8")
714 .to_owned(),
715 outcome,
716 )
717 })
718 .collect(),
719 )
720}
721
722fn compilation_artifact_progress(
723 completed: Vec<usize>,
724 policy: FilesystemMergePolicy,
725) -> CompilationArtifactWriteProgress {
726 let outcome = filesystem_outcome(policy);
727 CompilationArtifactWriteProgress::from_completed(
728 completed
729 .into_iter()
730 .map(|index| CompilationArtifactWriteEntry::new(index, outcome))
731 .collect(),
732 )
733}
734
735const fn filesystem_outcome(policy: FilesystemMergePolicy) -> WriteKeyOutcome {
736 match policy {
737 FilesystemMergePolicy::WriteNewTree | FilesystemMergePolicy::MergeCreateOnly => {
738 WriteKeyOutcome::Created
739 }
740 FilesystemMergePolicy::MergeReplaceExactFiles => WriteKeyOutcome::Written,
741 }
742}
743
744#[allow(clippy::result_large_err)]
745fn write_files(
746 files: &[PlannedFile<'_>],
747 destination: &Path,
748 policy: FilesystemMergePolicy,
749) -> Result<CoreReceipt, CoreError> {
750 write_files_before_commit(files, destination, policy, |_, _, _| {})
751}
752
753#[allow(clippy::result_large_err)]
754fn write_files_before_commit(
755 files: &[PlannedFile<'_>],
756 destination: &Path,
757 policy: FilesystemMergePolicy,
758 before_commit: impl FnMut(CommitScope, &Path, &Path),
759) -> Result<CoreReceipt, CoreError> {
760 write_files_with_faults(
761 files,
762 destination,
763 policy,
764 WriteFaults::default(),
765 before_commit,
766 )
767}
768
769#[allow(clippy::result_large_err)]
770fn write_files_with_faults(
771 files: &[PlannedFile<'_>],
772 destination: &Path,
773 policy: FilesystemMergePolicy,
774 faults: WriteFaults,
775 mut before_commit: impl FnMut(CommitScope, &Path, &Path),
776) -> Result<CoreReceipt, CoreError> {
777 if !merge_policy_supported(policy) {
778 return Err(CoreError {
779 phase: FilesystemWritePhase::Policy,
780 failed_target: None,
781 staging_residue: None,
782 staging_residue_status: None,
783 commit_certainty: CommitCertainty::NotCommitted,
784 completed: Vec::new(),
785 preflight_issues: None,
786 source: FilesystemWriteErrorCause::UnsupportedPolicy(policy),
787 });
788 }
789 if policy == FilesystemMergePolicy::WriteNewTree && faults.new_tree_policy_unsupported {
790 return Err(CoreError {
791 phase: FilesystemWritePhase::Policy,
792 failed_target: None,
793 staging_residue: None,
794 staging_residue_status: None,
795 commit_certainty: CommitCertainty::NotCommitted,
796 completed: Vec::new(),
797 preflight_issues: None,
798 source: FilesystemWriteErrorCause::UnsupportedPolicy(policy),
799 });
800 }
801
802 let destination_anchor = DestinationAnchor::capture(destination);
803 let mut issues = preflight(files, destination, policy);
804 let destination_anchor = match destination_anchor {
805 Ok(anchor) => Some(anchor),
806 Err(source) => {
807 issues.push(FilesystemWritePreflightIssue::InspectionFailed {
808 path: destination.to_owned(),
809 source: Arc::new(source),
810 });
811 None
812 }
813 };
814 issues.sort_by_key(FilesystemWritePreflightIssue::sort_key);
815 issues.dedup_by(|left, right| left.sort_key() == right.sort_key());
816 if !issues.is_empty() {
817 return Err(CoreError {
818 phase: FilesystemWritePhase::Preflight,
819 failed_target: None,
820 staging_residue: None,
821 staging_residue_status: None,
822 commit_certainty: CommitCertainty::NotCommitted,
823 completed: Vec::new(),
824 preflight_issues: Some(issues),
825 source: FilesystemWriteErrorCause::Preflight,
826 });
827 }
828
829 let destination_anchor =
830 destination_anchor.expect("successful destination capture accompanies clean preflight");
831 if !merge_policy_supported_for_plan(&destination_anchor, files, policy) {
832 return Err(CoreError {
833 phase: FilesystemWritePhase::Policy,
834 failed_target: None,
835 staging_residue: None,
836 staging_residue_status: None,
837 commit_certainty: CommitCertainty::NotCommitted,
838 completed: Vec::new(),
839 preflight_issues: None,
840 source: FilesystemWriteErrorCause::UnsupportedPolicy(policy),
841 });
842 }
843 if policy == FilesystemMergePolicy::WriteNewTree {
844 return write_new_tree(
845 files,
846 destination,
847 destination_anchor,
848 faults,
849 before_commit,
850 );
851 }
852 let destination_dir = prepare_destination(destination_anchor).map_err(|source| {
853 io_core_error(
854 FilesystemWritePhase::DirectoryCreate,
855 Some(destination.to_owned()),
856 None,
857 CommitCertainty::NotCommitted,
858 Vec::new(),
859 source,
860 )
861 })?;
862
863 let mut completed = Vec::with_capacity(files.len());
864 for (index, file) in files.iter().enumerate() {
865 let relative = Path::new(file.relative_path);
866 let target = destination.join(relative);
867 let parent_relative = relative.parent().unwrap_or_else(|| Path::new(""));
868 let target_name = relative
869 .file_name()
870 .expect("a canonical planned file has a file name");
871 let parent = match open_or_create_directory(&destination_dir, parent_relative) {
872 Ok(parent) => parent,
873 Err(source) => {
874 return Err(io_core_error(
875 FilesystemWritePhase::DirectoryCreate,
876 Some(target),
877 None,
878 CommitCertainty::NotCommitted,
879 completed,
880 source,
881 ));
882 }
883 };
884
885 let (staging, staging_name, mut writer) = match create_staging(&parent, &target) {
886 Ok(staging) => staging,
887 Err(source) => {
888 return Err(io_core_error(
889 FilesystemWritePhase::StagingCreate,
890 Some(target),
891 None,
892 CommitCertainty::NotCommitted,
893 completed,
894 source,
895 ));
896 }
897 };
898 let write_result = {
899 let mut fault_writer = FaultInjectingWriter::new(&mut writer, index, faults);
900 write_staging(&mut fault_writer, file.bytes)
901 };
902 if let Err((phase, source)) = write_result {
903 return Err(staging_core_error(
904 &parent,
905 &writer,
906 &staging_name,
907 io_core_error(
908 phase,
909 Some(target),
910 Some(staging),
911 CommitCertainty::NotCommitted,
912 completed,
913 source,
914 ),
915 ));
916 }
917 before_commit(CommitScope::PlannedFile(index), &target, &staging);
918 #[cfg(unix)]
919 if faults.ancestor_symlink_race_file == Some(index) && target.parent() != Some(destination)
920 {
921 use std::os::unix::fs::symlink;
922
923 let target_parent = target.parent().expect("a target has a parent");
924 let displaced = destination.join(format!(".typst-pack-race-{index}"));
925 let outside = destination
926 .parent()
927 .expect("a destination has a parent")
928 .join(format!(".typst-pack-outside-{index}"));
929 let _ = std::fs::create_dir(&outside);
930 if std::fs::rename(target_parent, &displaced).is_ok() {
931 let _ = symlink(&outside, target_parent);
932 }
933 }
934 if faults.commit_fault_file == Some(index) {
935 let _ = parent.remove_file(&staging_name);
936 }
937 if let Err(source) =
938 validate_directory_binding(&parent, target.parent().expect("a target has a parent"))
939 {
940 return Err(staging_core_error(
941 &parent,
942 &writer,
943 &staging_name,
944 io_core_error(
945 FilesystemWritePhase::Commit,
946 Some(target),
947 Some(staging),
948 CommitCertainty::NotCommitted,
949 completed,
950 source,
951 ),
952 ));
953 }
954 if let Err(source) = validate_commit_target(&parent, target_name, policy) {
955 return Err(staging_core_error(
956 &parent,
957 &writer,
958 &staging_name,
959 io_core_error(
960 FilesystemWritePhase::Commit,
961 Some(target),
962 Some(staging),
963 CommitCertainty::NotCommitted,
964 completed,
965 source,
966 ),
967 ));
968 }
969 if let Err(error) = commit_staging(&parent, &writer, &staging_name, target_name, policy) {
970 return Err(staging_core_error(
971 &parent,
972 &writer,
973 &staging_name,
974 io_core_error(
975 error.phase,
976 Some(target),
977 Some(staging),
978 error.commit_certainty,
979 completed,
980 error.source,
981 ),
982 ));
983 }
984 completed.push(index);
985 if let Err(source) =
986 validate_directory_binding(&parent, target.parent().expect("a target has a parent"))
987 {
988 return Err(io_core_error(
989 FilesystemWritePhase::Commit,
990 Some(target),
991 None,
992 CommitCertainty::Committed,
993 completed,
994 source,
995 ));
996 }
997 }
998
999 Ok(CoreReceipt { completed })
1000}
1001
1002#[allow(clippy::result_large_err)]
1003fn write_new_tree(
1004 files: &[PlannedFile<'_>],
1005 destination: &Path,
1006 mut anchor: DestinationAnchor,
1007 faults: WriteFaults,
1008 mut before_commit: impl FnMut(CommitScope, &Path, &Path),
1009) -> Result<CoreReceipt, CoreError> {
1010 let destination_name = anchor.missing.pop().ok_or_else(|| {
1011 io_core_error(
1012 FilesystemWritePhase::Commit,
1013 Some(destination.to_owned()),
1014 None,
1015 CommitCertainty::NotCommitted,
1016 Vec::new(),
1017 io::Error::new(
1018 io::ErrorKind::AlreadyExists,
1019 "new-tree destination appeared after preflight",
1020 ),
1021 )
1022 })?;
1023 for component in &anchor.missing {
1024 create_and_open_directory(&mut anchor.directory, component).map_err(|source| {
1025 io_core_error(
1026 FilesystemWritePhase::DirectoryCreate,
1027 Some(destination.to_owned()),
1028 None,
1029 CommitCertainty::NotCommitted,
1030 Vec::new(),
1031 source,
1032 )
1033 })?;
1034 }
1035 let parent = anchor.directory;
1036 let absolute_destination = absolute_destination(destination);
1037 let parent_path = absolute_destination
1038 .parent()
1039 .expect("a non-root new-tree destination has a parent");
1040 let (staging_path, staging_name, staging_root) =
1041 create_tree_staging(&parent, parent_path, faults).map_err(|error| CoreError {
1042 phase: error.phase,
1043 failed_target: Some(destination.to_owned()),
1044 staging_residue: error.staging_residue,
1045 staging_residue_status: Some(error.staging_residue_status),
1046 commit_certainty: CommitCertainty::NotCommitted,
1047 completed: Vec::new(),
1048 preflight_issues: None,
1049 source: FilesystemWriteErrorCause::Io(error.source),
1050 })?;
1051
1052 for (index, file) in files.iter().enumerate() {
1053 let target = destination.join(file.relative_path);
1054 let relative = Path::new(file.relative_path);
1055 let parent_relative = relative.parent().unwrap_or_else(|| Path::new(""));
1056 let file_name = relative
1057 .file_name()
1058 .expect("a canonical planned file has a file name");
1059 let file_parent =
1060 open_or_create_directory(&staging_root, parent_relative).map_err(|source| {
1061 tree_staging_error(
1062 &parent,
1063 &staging_root,
1064 &staging_name,
1065 io_core_error(
1066 FilesystemWritePhase::DirectoryCreate,
1067 Some(target.clone()),
1068 Some(staging_path.clone()),
1069 CommitCertainty::NotCommitted,
1070 Vec::new(),
1071 source,
1072 ),
1073 )
1074 })?;
1075 let mut options = OpenOptions::new();
1076 options.write(true).create_new(true);
1077 let mut writer = file_parent
1078 .open_with(file_name, &options)
1079 .map_err(|source| {
1080 tree_staging_error(
1081 &parent,
1082 &staging_root,
1083 &staging_name,
1084 io_core_error(
1085 FilesystemWritePhase::StagingCreate,
1086 Some(target.clone()),
1087 Some(staging_path.clone()),
1088 CommitCertainty::NotCommitted,
1089 Vec::new(),
1090 source,
1091 ),
1092 )
1093 })?;
1094 let write_result = {
1095 let mut fault_writer = FaultInjectingWriter::new(&mut writer, index, faults);
1096 write_staging(&mut fault_writer, file.bytes)
1097 };
1098 if let Err((phase, source)) = write_result {
1099 return Err(tree_staging_error(
1100 &parent,
1101 &staging_root,
1102 &staging_name,
1103 io_core_error(
1104 phase,
1105 Some(target),
1106 Some(staging_path),
1107 CommitCertainty::NotCommitted,
1108 Vec::new(),
1109 source,
1110 ),
1111 ));
1112 }
1113 }
1114
1115 before_commit(CommitScope::DestinationRoot, destination, &staging_path);
1116 if let Err(source) = validate_directory_binding(&parent, parent_path) {
1117 return Err(tree_staging_error(
1118 &parent,
1119 &staging_root,
1120 &staging_name,
1121 io_core_error(
1122 FilesystemWritePhase::Commit,
1123 Some(destination.to_owned()),
1124 Some(staging_path),
1125 CommitCertainty::NotCommitted,
1126 Vec::new(),
1127 source,
1128 ),
1129 ));
1130 }
1131 if let Err(source) = validate_new_tree_commit_target(&parent, &destination_name) {
1132 let commit_certainty =
1133 observe_tree_commit_certainty(&parent, &staging_root, &staging_name, &destination_name);
1134 let completed = if commit_certainty == CommitCertainty::Committed {
1135 planned_file_indices(files)
1136 } else {
1137 Vec::new()
1138 };
1139 return Err(tree_staging_error(
1140 &parent,
1141 &staging_root,
1142 &staging_name,
1143 io_core_error(
1144 FilesystemWritePhase::Commit,
1145 Some(destination.to_owned()),
1146 Some(staging_path),
1147 commit_certainty,
1148 completed,
1149 source,
1150 ),
1151 ));
1152 }
1153 let commit_result = if faults.new_tree_commit_unsupported {
1154 Err(io::Error::new(
1155 io::ErrorKind::Unsupported,
1156 "scripted unsupported new-tree commit",
1157 ))
1158 } else {
1159 commit_new_tree(&parent, &staging_root, &staging_name, &destination_name)
1160 };
1161 if let Err(source) = commit_result {
1162 let commit_certainty =
1163 observe_tree_commit_certainty(&parent, &staging_root, &staging_name, &destination_name);
1164 let completed = if commit_certainty == CommitCertainty::Committed {
1165 planned_file_indices(files)
1166 } else {
1167 Vec::new()
1168 };
1169 let source = if commit_policy_unsupported(&source) {
1170 FilesystemWriteErrorCause::UnsupportedPolicy(FilesystemMergePolicy::WriteNewTree)
1171 } else {
1172 FilesystemWriteErrorCause::Io(source)
1173 };
1174 let mut error = CoreError {
1175 phase: FilesystemWritePhase::Commit,
1176 failed_target: Some(destination.to_owned()),
1177 staging_residue: Some(staging_path),
1178 staging_residue_status: None,
1179 commit_certainty,
1180 completed,
1181 preflight_issues: None,
1182 source,
1183 };
1184 error.staging_residue_status = Some(observe_captured_tree_staging(
1185 &parent,
1186 &staging_root,
1187 &staging_name,
1188 error.staging_residue.as_deref(),
1189 ));
1190 return Err(error);
1191 }
1192
1193 let completed = planned_file_indices(files);
1194 if let Err(source) = validate_directory_binding(&parent, parent_path) {
1195 return Err(io_core_error(
1196 FilesystemWritePhase::Commit,
1197 Some(destination.to_owned()),
1198 None,
1199 CommitCertainty::Committed,
1200 completed,
1201 source,
1202 ));
1203 }
1204
1205 Ok(CoreReceipt { completed })
1206}
1207
1208fn planned_file_indices(files: &[PlannedFile<'_>]) -> Vec<usize> {
1209 (0..files.len()).collect()
1210}
1211
1212fn write_staging(
1213 writer: &mut impl Write,
1214 bytes: &[u8],
1215) -> Result<(), (FilesystemWritePhase, io::Error)> {
1216 let mut written = 0;
1217 while written < bytes.len() {
1218 match writer.write(&bytes[written..]) {
1219 Ok(0) => {
1220 return Err((
1221 FilesystemWritePhase::StagingWrite,
1222 io::Error::new(
1223 io::ErrorKind::WriteZero,
1224 "failed to write the complete staging file",
1225 ),
1226 ));
1227 }
1228 Ok(count) => written += count,
1229 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
1230 Err(error) => {
1231 return Err((FilesystemWritePhase::StagingWrite, error));
1232 }
1233 }
1234 }
1235 writer
1236 .flush()
1237 .map_err(|error| (FilesystemWritePhase::StagingFlush, error))
1238}
1239
1240struct FaultInjectingWriter<'a, W> {
1241 writer: &'a mut W,
1242 file_index: usize,
1243 faults: WriteFaults,
1244 written: usize,
1245}
1246
1247impl<'a, W> FaultInjectingWriter<'a, W> {
1248 fn new(writer: &'a mut W, file_index: usize, faults: WriteFaults) -> Self {
1249 Self {
1250 writer,
1251 file_index,
1252 faults,
1253 written: 0,
1254 }
1255 }
1256}
1257
1258impl<W: Write> Write for FaultInjectingWriter<'_, W> {
1259 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
1260 if self.faults.write_fault_file == Some(self.file_index)
1261 && self.written >= self.faults.write_fault_after
1262 {
1263 return Err(io::Error::other("scripted staging write fault"));
1264 }
1265 let before_fault = if self.faults.write_fault_file == Some(self.file_index) {
1266 self.faults.write_fault_after.saturating_sub(self.written)
1267 } else {
1268 usize::MAX
1269 };
1270 let limit = buffer
1271 .len()
1272 .min(self.faults.maximum_write)
1273 .min(before_fault);
1274 let written = self.writer.write(&buffer[..limit])?;
1275 self.written += written;
1276 Ok(written)
1277 }
1278
1279 fn flush(&mut self) -> io::Result<()> {
1280 if self.faults.flush_fault_file == Some(self.file_index) {
1281 Err(io::Error::other("scripted staging flush fault"))
1282 } else {
1283 self.writer.flush()
1284 }
1285 }
1286}
1287
1288fn preflight(
1289 files: &[PlannedFile<'_>],
1290 destination: &Path,
1291 policy: FilesystemMergePolicy,
1292) -> Vec<FilesystemWritePreflightIssue> {
1293 let mut issues = Vec::new();
1294 let mut aliases = BTreeMap::<PlatformAliasKey, &Path>::new();
1295 let case_insensitive = platform_case_insensitive(destination);
1296 let limits = platform_path_limits(destination);
1297
1298 inspect_destination_platform_path(destination, limits, &mut issues);
1299 inspect_destination_root(destination, policy, &mut issues);
1300 for file in files {
1301 let relative = Path::new(file.relative_path);
1302 if !is_canonical_relative_path(relative) {
1303 issues.push(FilesystemWritePreflightIssue::InvalidRelativePath {
1304 relative_path: file.relative_path.to_owned(),
1305 });
1306 continue;
1307 }
1308 inspect_platform_path(file.relative_path, destination, limits, &mut issues);
1309
1310 let alias = platform_alias_key(file.relative_path, case_insensitive);
1311 if let Some(first) = aliases.insert(alias, file.relative_path)
1312 && first != file.relative_path
1313 {
1314 issues.push(FilesystemWritePreflightIssue::PathAlias {
1315 first_path: first.to_owned(),
1316 second_path: file.relative_path.to_owned(),
1317 });
1318 }
1319
1320 inspect_target(file.relative_path, destination, policy, &mut issues);
1321 }
1322 issues.sort_by_key(FilesystemWritePreflightIssue::sort_key);
1323 issues.dedup_by(|left, right| left.sort_key() == right.sort_key());
1324 issues
1325}
1326
1327fn inspect_destination_platform_path(
1328 destination: &Path,
1329 limits: PlatformPathLimits,
1330 issues: &mut Vec<FilesystemWritePreflightIssue>,
1331) {
1332 for component in destination
1333 .components()
1334 .filter_map(|component| match component {
1335 Component::Normal(component) => Some(component),
1336 _ => None,
1337 })
1338 {
1339 let rendered = component.to_string_lossy();
1340 if path_length(Path::new(component)) > limits.component {
1341 issues.push(FilesystemWritePreflightIssue::DestinationComponentTooLong {
1342 path: destination.to_owned(),
1343 component: rendered.clone().into_owned(),
1344 });
1345 }
1346 if is_reserved_component(&rendered) {
1347 issues.push(FilesystemWritePreflightIssue::DestinationReservedName {
1348 path: destination.to_owned(),
1349 component: rendered.into_owned(),
1350 });
1351 }
1352 }
1353 let absolute = absolute_destination(destination);
1354 if path_length(&absolute).saturating_sub(limits.path_prefix) > limits.path {
1355 issues.push(FilesystemWritePreflightIssue::DestinationPathTooLong {
1356 path: destination.to_owned(),
1357 });
1358 }
1359}
1360
1361fn inspect_destination_root(
1362 destination: &Path,
1363 policy: FilesystemMergePolicy,
1364 issues: &mut Vec<FilesystemWritePreflightIssue>,
1365) {
1366 match std::fs::symlink_metadata(destination) {
1367 Ok(metadata) => {
1368 if policy == FilesystemMergePolicy::WriteNewTree {
1369 issues.push(FilesystemWritePreflightIssue::ExistingDestinationRoot {
1370 path: destination.to_owned(),
1371 });
1372 }
1373 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1374 issues.push(FilesystemWritePreflightIssue::ConflictingDestinationRoot {
1375 path: destination.to_owned(),
1376 kind: entry_kind(&metadata),
1377 });
1378 }
1379 }
1380 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1381 Err(error) => issues.push(FilesystemWritePreflightIssue::InspectionFailed {
1382 path: destination.to_owned(),
1383 source: Arc::new(error),
1384 }),
1385 }
1386}
1387
1388fn inspect_target(
1389 relative_path: &Path,
1390 destination: &Path,
1391 policy: FilesystemMergePolicy,
1392 issues: &mut Vec<FilesystemWritePreflightIssue>,
1393) {
1394 let target = destination.join(relative_path);
1395 let mut ancestor = target.parent();
1396 while let Some(path) = ancestor.filter(|path| path.starts_with(destination)) {
1397 match std::fs::symlink_metadata(path) {
1398 Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => {
1399 issues.push(FilesystemWritePreflightIssue::ConflictingAncestor {
1400 relative_path: relative_path.to_owned(),
1401 ancestor: path.to_owned(),
1402 kind: entry_kind(&metadata),
1403 });
1404 }
1405 Ok(_) => {}
1406 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1407 Err(error) if error.kind() == io::ErrorKind::NotADirectory => {}
1408 Err(error) => issues.push(FilesystemWritePreflightIssue::InspectionFailed {
1409 path: path.to_owned(),
1410 source: Arc::new(error),
1411 }),
1412 }
1413 if path == destination {
1414 break;
1415 }
1416 ancestor = path.parent();
1417 }
1418
1419 match std::fs::symlink_metadata(&target) {
1420 Ok(metadata)
1421 if policy != FilesystemMergePolicy::MergeReplaceExactFiles && metadata.is_file() =>
1422 {
1423 issues.push(FilesystemWritePreflightIssue::ExistingTarget {
1424 relative_path: relative_path.to_owned(),
1425 });
1426 }
1427 Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => {
1428 issues.push(FilesystemWritePreflightIssue::ConflictingTarget {
1429 relative_path: relative_path.to_owned(),
1430 kind: entry_kind(&metadata),
1431 });
1432 }
1433 Ok(_) => {}
1434 Err(error)
1435 if matches!(
1436 error.kind(),
1437 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
1438 ) => {}
1439 Err(error) => issues.push(FilesystemWritePreflightIssue::InspectionFailed {
1440 path: target,
1441 source: Arc::new(error),
1442 }),
1443 }
1444}
1445
1446fn inspect_platform_path(
1447 relative_path: &Path,
1448 destination: &Path,
1449 limits: PlatformPathLimits,
1450 issues: &mut Vec<FilesystemWritePreflightIssue>,
1451) {
1452 for component in relative_path
1453 .components()
1454 .filter_map(|component| match component {
1455 Component::Normal(component) => Some(component),
1456 _ => None,
1457 })
1458 {
1459 let rendered_component = component.to_string_lossy();
1460 if path_length(Path::new(component)) > limits.component {
1461 issues.push(FilesystemWritePreflightIssue::ComponentTooLong {
1462 relative_path: relative_path.to_owned(),
1463 component: rendered_component.clone().into_owned(),
1464 });
1465 }
1466 if is_reserved_component(&rendered_component) {
1467 issues.push(FilesystemWritePreflightIssue::ReservedName {
1468 relative_path: relative_path.to_owned(),
1469 component: rendered_component.into_owned(),
1470 });
1471 }
1472 }
1473 let target = destination.join(relative_path);
1474 let target = if target.is_absolute() {
1475 target
1476 } else {
1477 std::env::current_dir()
1478 .map(|current| current.join(&target))
1479 .unwrap_or(target)
1480 };
1481 if path_length(&target).saturating_sub(limits.path_prefix) > limits.path {
1482 issues.push(FilesystemWritePreflightIssue::PathTooLong {
1483 relative_path: relative_path.to_owned(),
1484 });
1485 }
1486}
1487
1488fn is_canonical_relative_path(path: &Path) -> bool {
1489 !path.as_os_str().is_empty()
1490 && !path.is_absolute()
1491 && path
1492 .components()
1493 .all(|component| matches!(component, Component::Normal(_)))
1494}
1495
1496#[derive(Eq, Ord, PartialEq, PartialOrd)]
1497enum PlatformAliasKey {
1498 Exact(std::ffi::OsString),
1499 Folded(String),
1500}
1501
1502#[cfg(windows)]
1503fn platform_alias_key(path: &Path, _case_insensitive: bool) -> PlatformAliasKey {
1504 path.to_str().map_or_else(
1505 || PlatformAliasKey::Exact(path.as_os_str().to_owned()),
1506 |path| {
1507 PlatformAliasKey::Folded(
1508 path.split(['/', '\\'])
1509 .map(|component| component.trim_end_matches([' ', '.']).to_lowercase())
1510 .collect::<Vec<_>>()
1511 .join("/"),
1512 )
1513 },
1514 )
1515}
1516
1517#[cfg(any(target_os = "macos", target_os = "ios"))]
1518fn platform_alias_key(path: &Path, case_insensitive: bool) -> PlatformAliasKey {
1519 use unicode_normalization::UnicodeNormalization;
1520
1521 path.to_str().map_or_else(
1522 || PlatformAliasKey::Exact(path.as_os_str().to_owned()),
1523 |path| {
1524 let normalized = path.nfd().collect::<String>();
1525 PlatformAliasKey::Folded(if case_insensitive {
1526 normalized.to_lowercase()
1527 } else {
1528 normalized
1529 })
1530 },
1531 )
1532}
1533
1534#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))]
1535fn platform_alias_key(path: &Path, case_insensitive: bool) -> PlatformAliasKey {
1536 if !case_insensitive {
1537 return PlatformAliasKey::Exact(path.as_os_str().to_owned());
1538 }
1539 path.to_str().map_or_else(
1540 || PlatformAliasKey::Exact(path.as_os_str().to_owned()),
1541 |path| {
1542 use unicode_normalization::UnicodeNormalization;
1543
1544 PlatformAliasKey::Folded(path.nfc().collect::<String>().to_lowercase())
1545 },
1546 )
1547}
1548
1549#[cfg(windows)]
1550fn platform_case_insensitive(destination: &Path) -> bool {
1551 use std::mem::{size_of, zeroed};
1552 use std::os::windows::io::AsRawHandle;
1553 use windows_sys::Win32::Storage::FileSystem::{
1554 FILE_CASE_SENSITIVE_INFO, FileCaseSensitiveInfo, GetFileInformationByHandleEx,
1555 };
1556
1557 let Some(directory) = nearest_existing_directory(destination)
1558 .and_then(|path| open_directory_nofollow(&path).ok())
1559 else {
1560 return true;
1561 };
1562 let mut info = unsafe { zeroed::<FILE_CASE_SENSITIVE_INFO>() };
1563 let result = unsafe {
1564 GetFileInformationByHandleEx(
1565 directory.as_raw_handle().cast(),
1566 FileCaseSensitiveInfo,
1567 std::ptr::addr_of_mut!(info).cast(),
1568 size_of::<FILE_CASE_SENSITIVE_INFO>() as u32,
1569 )
1570 };
1571 result == 0 || info.Flags & 1 == 0
1572}
1573
1574#[cfg(any(target_os = "macos", target_os = "ios"))]
1575fn platform_case_insensitive(destination: &Path) -> bool {
1576 use std::ffi::CString;
1577 use std::mem::MaybeUninit;
1578 use std::os::unix::ffi::OsStrExt;
1579
1580 const MNT_CASE_SENSITIVE: u32 = 0x0000_0040;
1581 let absolute = absolute_destination(destination);
1582 let mut probe = absolute.as_path();
1583 while !probe.exists() {
1584 let Some(parent) = probe.parent() else {
1585 return false;
1586 };
1587 probe = parent;
1588 }
1589 let Ok(probe) = CString::new(probe.as_os_str().as_bytes()) else {
1590 return false;
1591 };
1592 let mut status = MaybeUninit::<libc::statfs>::uninit();
1593 if unsafe { libc::statfs(probe.as_ptr(), status.as_mut_ptr()) } != 0 {
1594 return false;
1595 }
1596 let status = unsafe { status.assume_init() };
1597 status.f_flags & MNT_CASE_SENSITIVE == 0
1598}
1599
1600#[cfg(any(target_os = "linux", target_os = "android"))]
1601fn platform_case_insensitive(destination: &Path) -> bool {
1602 use std::ffi::CString;
1603 use std::mem::MaybeUninit;
1604 use std::os::fd::AsRawFd;
1605 use std::os::unix::ffi::OsStrExt;
1606
1607 #[cfg(target_pointer_width = "64")]
1608 const FS_IOC_GETFLAGS: libc::c_ulong = 0x8008_6601;
1609 #[cfg(target_pointer_width = "32")]
1610 const FS_IOC_GETFLAGS: libc::c_ulong = 0x8004_6601;
1611 const FS_CASEFOLD_FL: libc::c_int = 0x4000_0000;
1612 const MSDOS_SUPER_MAGIC: u64 = 0x4d44;
1613 const EXFAT_SUPER_MAGIC: u64 = 0x2011_bab0;
1614 const NTFS_SB_MAGIC: u64 = 0x5346_544e;
1615 let absolute = absolute_destination(destination);
1616 let mut probe = absolute.as_path();
1617 while !probe.exists() {
1618 let Some(parent) = probe.parent() else {
1619 return false;
1620 };
1621 probe = parent;
1622 }
1623 let Ok(directory) = std::fs::File::open(probe) else {
1624 return false;
1625 };
1626 let mut flags = 0 as libc::c_int;
1627 if unsafe { libc::ioctl(directory.as_raw_fd(), FS_IOC_GETFLAGS, &mut flags) == 0 }
1628 && flags & FS_CASEFOLD_FL != 0
1629 {
1630 return true;
1631 }
1632 let Ok(probe) = CString::new(probe.as_os_str().as_bytes()) else {
1633 return false;
1634 };
1635 let mut status = MaybeUninit::<libc::statfs>::uninit();
1636 if unsafe { libc::statfs(probe.as_ptr(), status.as_mut_ptr()) } != 0 {
1637 return false;
1638 }
1639 matches!(
1640 unsafe { status.assume_init() }.f_type as u64,
1641 MSDOS_SUPER_MAGIC | EXFAT_SUPER_MAGIC | NTFS_SB_MAGIC
1642 )
1643}
1644
1645#[cfg(not(any(
1646 windows,
1647 target_os = "macos",
1648 target_os = "ios",
1649 target_os = "linux",
1650 target_os = "android"
1651)))]
1652const fn platform_case_insensitive(_destination: &Path) -> bool {
1653 false
1654}
1655
1656#[derive(Clone, Copy)]
1657struct PlatformPathLimits {
1658 component: usize,
1659 path: usize,
1660 path_prefix: usize,
1661}
1662
1663#[cfg(unix)]
1664fn platform_path_limits(destination: &Path) -> PlatformPathLimits {
1665 use std::ffi::CString;
1666 use std::os::unix::ffi::OsStrExt;
1667
1668 let absolute = absolute_destination(destination);
1669 let mut probe = absolute.as_path();
1670 while !probe.exists() {
1671 let Some(parent) = probe.parent() else {
1672 break;
1673 };
1674 probe = parent;
1675 }
1676 let queried = CString::new(probe.as_os_str().as_bytes())
1677 .ok()
1678 .map(|probe| {
1679 let component = unsafe { libc::pathconf(probe.as_ptr(), libc::_PC_NAME_MAX) };
1680 let path = unsafe { libc::pathconf(probe.as_ptr(), libc::_PC_PATH_MAX) };
1681 (component, path)
1682 });
1683 PlatformPathLimits {
1684 component: queried
1685 .filter(|(component, _)| *component > 0)
1686 .map_or(255, |(component, _)| component as usize),
1687 path: queried
1688 .filter(|(_, path)| *path > 0)
1689 .map_or(libc::PATH_MAX as usize, |(_, path)| path as usize),
1690 path_prefix: path_length(probe).saturating_add(1),
1691 }
1692}
1693
1694fn absolute_destination(destination: &Path) -> PathBuf {
1695 if destination.is_absolute() {
1696 destination.to_owned()
1697 } else {
1698 std::env::current_dir()
1699 .map(|current| current.join(destination))
1700 .unwrap_or_else(|_| destination.to_owned())
1701 }
1702}
1703
1704#[cfg(windows)]
1705fn nearest_existing_directory(destination: &Path) -> Option<PathBuf> {
1706 let absolute = absolute_destination(destination);
1707 absolute
1708 .ancestors()
1709 .find(|path| path.is_dir())
1710 .map(Path::to_owned)
1711}
1712
1713#[cfg(windows)]
1714fn platform_path_limits(destination: &Path) -> PlatformPathLimits {
1715 let component = nearest_existing_directory(destination)
1716 .and_then(|path| open_directory_nofollow(&path).ok())
1717 .and_then(|directory| windows_volume_information(&directory).ok())
1718 .map_or(255, |(component, _)| component as usize);
1719 PlatformPathLimits {
1720 component,
1721 path: 32_767,
1722 path_prefix: 0,
1723 }
1724}
1725
1726#[cfg(windows)]
1727fn windows_volume_information(directory: &std::fs::File) -> io::Result<(u32, u32)> {
1728 use std::os::windows::io::AsRawHandle;
1729 use windows_sys::Win32::Storage::FileSystem::GetVolumeInformationByHandleW;
1730
1731 let mut component = 0;
1732 let mut flags = 0;
1733 let result = unsafe {
1734 GetVolumeInformationByHandleW(
1735 directory.as_raw_handle().cast(),
1736 std::ptr::null_mut(),
1737 0,
1738 std::ptr::null_mut(),
1739 &mut component,
1740 &mut flags,
1741 std::ptr::null_mut(),
1742 0,
1743 )
1744 };
1745 if result != 0 {
1746 Ok((component, flags))
1747 } else {
1748 Err(io::Error::last_os_error())
1749 }
1750}
1751
1752#[cfg(not(any(unix, windows)))]
1753const fn platform_path_limits(_destination: &Path) -> PlatformPathLimits {
1754 PlatformPathLimits {
1755 component: 255,
1756 path: usize::MAX,
1757 path_prefix: 0,
1758 }
1759}
1760
1761#[cfg(windows)]
1762fn is_reserved_component(component: &str) -> bool {
1763 if component.ends_with([' ', '.'])
1764 || component
1765 .encode_utf16()
1766 .any(|unit| unit < 32 || matches!(unit, 34 | 42 | 47 | 58 | 60 | 62 | 63 | 92 | 124))
1767 {
1768 return true;
1769 }
1770 let stem = component
1771 .split('.')
1772 .next()
1773 .unwrap_or(component)
1774 .to_ascii_uppercase();
1775 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
1776 || stem
1777 .strip_prefix("COM")
1778 .or_else(|| stem.strip_prefix("LPT"))
1779 .is_some_and(|number| {
1780 matches!(
1781 number,
1782 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
1783 )
1784 })
1785}
1786
1787#[cfg(not(windows))]
1788const fn is_reserved_component(_component: &str) -> bool {
1789 false
1790}
1791
1792#[cfg(windows)]
1793fn path_length(path: &Path) -> usize {
1794 use std::os::windows::ffi::OsStrExt;
1795 path.as_os_str().encode_wide().count()
1796}
1797
1798#[cfg(not(windows))]
1799fn path_length(path: &Path) -> usize {
1800 path.as_os_str().len()
1801}
1802
1803fn entry_kind(metadata: &std::fs::Metadata) -> FilesystemDestinationEntryKind {
1804 if metadata.file_type().is_symlink() {
1805 FilesystemDestinationEntryKind::Symlink
1806 } else if metadata.is_file() {
1807 FilesystemDestinationEntryKind::File
1808 } else if metadata.is_dir() {
1809 FilesystemDestinationEntryKind::Directory
1810 } else {
1811 FilesystemDestinationEntryKind::Other
1812 }
1813}
1814
1815struct DestinationAnchor {
1816 directory: Dir,
1817 missing: Vec<std::ffi::OsString>,
1818}
1819
1820impl DestinationAnchor {
1821 fn capture(destination: &Path) -> io::Result<Self> {
1822 let absolute = absolute_destination(destination);
1823 let root = absolute.ancestors().last().ok_or_else(|| {
1824 io::Error::new(
1825 io::ErrorKind::InvalidInput,
1826 "destination has no filesystem root",
1827 )
1828 })?;
1829 let relative = absolute.strip_prefix(root).map_err(|_| {
1830 io::Error::new(
1831 io::ErrorKind::InvalidInput,
1832 "destination is not beneath its filesystem root",
1833 )
1834 })?;
1835 let mut directory = Dir::open_ambient_dir(root, ambient_authority())?;
1836 let mut missing = Vec::new();
1837 let mut components = relative.components();
1838 while let Some(component) = components.next() {
1839 let Component::Normal(component) = component else {
1840 return Err(io::Error::new(
1841 io::ErrorKind::InvalidInput,
1842 "destination contains an unsupported component",
1843 ));
1844 };
1845 match directory.symlink_metadata(component) {
1846 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
1847 let child = open_child_directory_nofollow(&directory, component)?;
1848 let metadata = directory.symlink_metadata(component)?;
1849 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1850 return Err(io::Error::other(format!(
1851 "destination component {component:?} changed while it was opened"
1852 )));
1853 }
1854 directory = child;
1855 }
1856 Ok(_) => {
1857 return Err(io::Error::other(format!(
1858 "destination component {component:?} is not a real directory"
1859 )));
1860 }
1861 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1862 missing.push(component.to_owned());
1863 for component in components {
1864 let Component::Normal(component) = component else {
1865 return Err(io::Error::new(
1866 io::ErrorKind::InvalidInput,
1867 "destination contains an unsupported component",
1868 ));
1869 };
1870 missing.push(component.to_owned());
1871 }
1872 break;
1873 }
1874 Err(error) => return Err(error),
1875 }
1876 }
1877 Ok(Self { directory, missing })
1878 }
1879}
1880
1881#[cfg(windows)]
1882fn open_directory_nofollow(path: &Path) -> io::Result<std::fs::File> {
1883 use std::os::windows::fs::{FileTypeExt, OpenOptionsExt};
1884
1885 const FILE_SHARE_ALL: u32 = 0x7;
1886 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1887 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1888
1889 let file = std::fs::OpenOptions::new()
1895 .read(true)
1896 .share_mode(FILE_SHARE_ALL)
1897 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
1898 .open(path)?;
1899 let file_type = file.metadata()?.file_type();
1900 if file_type.is_symlink_dir() || !file_type.is_dir() {
1901 Err(io::Error::other(
1902 "destination ancestor is not a real directory",
1903 ))
1904 } else {
1905 Ok(file)
1906 }
1907}
1908
1909fn prepare_destination(mut anchor: DestinationAnchor) -> io::Result<Dir> {
1910 for component in &anchor.missing {
1911 create_and_open_directory(&mut anchor.directory, component)?;
1912 }
1913 Ok(anchor.directory)
1914}
1915
1916fn open_or_create_directory(root: &Dir, relative: &Path) -> io::Result<Dir> {
1917 let mut directory = root.try_clone()?;
1918 for component in relative.components() {
1919 let Component::Normal(component) = component else {
1920 return Err(io::Error::new(
1921 io::ErrorKind::InvalidInput,
1922 "planned directory is not canonical and relative",
1923 ));
1924 };
1925 create_and_open_directory(&mut directory, component)?;
1926 }
1927 Ok(directory)
1928}
1929
1930fn create_and_open_directory(directory: &mut Dir, component: &std::ffi::OsStr) -> io::Result<()> {
1931 match directory.create_dir(component) {
1932 Ok(()) => {}
1933 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1934 Err(error) => return Err(error),
1935 }
1936 let metadata = directory.symlink_metadata(component)?;
1937 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1938 return Err(io::Error::other(format!(
1939 "destination component {component:?} is not a real directory"
1940 )));
1941 }
1942 let child = open_child_directory_nofollow(directory, component)?;
1943 let metadata = directory.symlink_metadata(component)?;
1944 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1945 return Err(io::Error::other(format!(
1946 "destination component {component:?} changed while it was opened"
1947 )));
1948 }
1949 *directory = child;
1950 Ok(())
1951}
1952
1953fn open_child_directory_nofollow(directory: &Dir, component: &std::ffi::OsStr) -> io::Result<Dir> {
1954 let parent = directory.try_clone()?.into_std_file();
1955 cap_primitives::fs::open_dir_nofollow(&parent, Path::new(component)).map(Dir::from_std_file)
1956}
1957
1958fn validate_commit_target(
1959 parent: &Dir,
1960 target_name: &std::ffi::OsStr,
1961 policy: FilesystemMergePolicy,
1962) -> io::Result<()> {
1963 match parent.symlink_metadata(target_name) {
1964 Ok(_) if policy == FilesystemMergePolicy::MergeCreateOnly => Err(io::Error::new(
1965 io::ErrorKind::AlreadyExists,
1966 "create-only planned target appeared after preflight",
1967 )),
1968 Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(()),
1969 Ok(_) => Err(io::Error::other(
1970 "planned target changed to a non-file after preflight",
1971 )),
1972 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1973 Err(error) => Err(error),
1974 }
1975}
1976
1977fn validate_new_tree_commit_target(parent: &Dir, target_name: &std::ffi::OsStr) -> io::Result<()> {
1978 match parent.symlink_metadata(target_name) {
1979 Ok(_) => Err(io::Error::new(
1980 io::ErrorKind::AlreadyExists,
1981 "new-tree destination appeared after preflight",
1982 )),
1983 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1984 Err(error) => Err(error),
1985 }
1986}
1987
1988fn validate_directory_binding(directory: &Dir, ambient_path: &Path) -> io::Result<()> {
1989 let metadata = std::fs::symlink_metadata(ambient_path)?;
1990 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1991 return Err(io::Error::other(
1992 "destination directory binding changed during write",
1993 ));
1994 }
1995 let captured = same_file::Handle::from_file(directory.try_clone()?.into_std_file())?;
1996 let ambient = same_file::Handle::from_path(ambient_path)?;
1997 if captured == ambient {
1998 Ok(())
1999 } else {
2000 Err(io::Error::other(
2001 "destination directory binding changed during write",
2002 ))
2003 }
2004}
2005
2006fn create_staging(
2007 parent: &Dir,
2008 target: &Path,
2009) -> io::Result<(PathBuf, std::ffi::OsString, cap_std::fs::File)> {
2010 static NEXT_STAGE: AtomicU64 = AtomicU64::new(0);
2011 const ATTEMPTS: usize = 128;
2012
2013 let file_name = target
2014 .file_name()
2015 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))?;
2016 for _ in 0..ATTEMPTS {
2017 let sequence = NEXT_STAGE.fetch_add(1, Ordering::Relaxed);
2018 let mut staging_name = file_name.to_os_string();
2019 staging_name.push(format!(
2020 ".typst-pack-stage-{}-{sequence}",
2021 std::process::id()
2022 ));
2023 let staging = target
2024 .parent()
2025 .expect("a target has a parent")
2026 .join(&staging_name);
2027 let mut options = OpenOptions::new();
2028 options.write(true).create_new(true);
2029 #[cfg(windows)]
2030 {
2031 use cap_std::fs::OpenOptionsExt;
2032
2033 const DELETE: u32 = 0x0001_0000;
2034 const GENERIC_WRITE: u32 = 0x4000_0000;
2035 const FILE_SHARE_READ: u32 = 0x1;
2036 const FILE_SHARE_WRITE: u32 = 0x2;
2037 const FILE_SHARE_DELETE: u32 = 0x4;
2038
2039 options
2040 .access_mode(GENERIC_WRITE | DELETE)
2041 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
2042 }
2043 match parent.open_with(&staging_name, &options) {
2044 Ok(file) => return Ok((staging, staging_name, file)),
2045 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
2046 Err(error) => return Err(error),
2047 }
2048 }
2049 Err(io::Error::new(
2050 io::ErrorKind::AlreadyExists,
2051 "could not allocate a unique same-directory staging file",
2052 ))
2053}
2054
2055struct CreateTreeStagingError {
2056 phase: FilesystemWritePhase,
2057 staging_residue: Option<PathBuf>,
2058 staging_residue_status: StagingResidueStatus,
2059 source: io::Error,
2060}
2061
2062fn create_tree_staging(
2063 parent: &Dir,
2064 parent_path: &Path,
2065 faults: WriteFaults,
2066) -> Result<(PathBuf, std::ffi::OsString, Dir), CreateTreeStagingError> {
2067 static NEXT_TREE_STAGE: AtomicU64 = AtomicU64::new(0);
2068 const ATTEMPTS: usize = 128;
2069
2070 for _ in 0..ATTEMPTS {
2071 let sequence = NEXT_TREE_STAGE.fetch_add(1, Ordering::Relaxed);
2072 let staging_name = std::ffi::OsString::from(format!(
2073 ".typst-pack-tree-stage-{}-{sequence}",
2074 std::process::id()
2075 ));
2076 match parent.create_dir(&staging_name) {
2077 Ok(()) => {
2078 let staging_path = parent_path.join(&staging_name);
2079 let open_result = if faults.tree_staging_open_fault {
2080 Err(io::Error::other("scripted tree staging open fault"))
2081 } else {
2082 open_tree_staging_directory(parent, &staging_name)
2083 };
2084 match open_result {
2085 Ok(staging_root) => {
2086 return Ok((staging_path, staging_name, staging_root));
2087 }
2088 Err(source) => match if faults.tree_staging_cleanup_fault {
2089 Err(io::Error::other("scripted tree staging cleanup fault"))
2090 } else {
2091 parent.remove_dir(&staging_name)
2092 } {
2093 Ok(()) => {
2094 return Err(CreateTreeStagingError {
2095 phase: FilesystemWritePhase::StagingCreate,
2096 staging_residue: None,
2097 staging_residue_status: StagingResidueStatus::Absent,
2098 source,
2099 });
2100 }
2101 Err(cleanup) if cleanup.kind() == io::ErrorKind::NotFound => {
2102 return Err(CreateTreeStagingError {
2103 phase: FilesystemWritePhase::StagingCreate,
2104 staging_residue: None,
2105 staging_residue_status: StagingResidueStatus::Absent,
2106 source,
2107 });
2108 }
2109 Err(cleanup) => {
2110 return Err(CreateTreeStagingError {
2111 phase: FilesystemWritePhase::StagingCleanup,
2112 staging_residue: Some(staging_path),
2113 staging_residue_status: StagingResidueStatus::Indeterminate,
2114 source: cleanup,
2115 });
2116 }
2117 },
2118 }
2119 }
2120 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
2121 Err(source) => {
2122 return Err(CreateTreeStagingError {
2123 phase: FilesystemWritePhase::StagingCreate,
2124 staging_residue: None,
2125 staging_residue_status: StagingResidueStatus::Absent,
2126 source,
2127 });
2128 }
2129 }
2130 }
2131 Err(CreateTreeStagingError {
2132 phase: FilesystemWritePhase::StagingCreate,
2133 staging_residue: None,
2134 staging_residue_status: StagingResidueStatus::Absent,
2135 source: io::Error::new(
2136 io::ErrorKind::AlreadyExists,
2137 "could not allocate a unique sibling staging directory",
2138 ),
2139 })
2140}
2141
2142#[cfg(not(windows))]
2143fn open_tree_staging_directory(parent: &Dir, name: &std::ffi::OsStr) -> io::Result<Dir> {
2144 open_child_directory_nofollow(parent, name)
2145}
2146
2147#[cfg(windows)]
2148fn open_tree_staging_directory(parent: &Dir, name: &std::ffi::OsStr) -> io::Result<Dir> {
2149 use cap_std::fs::OpenOptionsExt;
2150 use std::os::windows::fs::FileTypeExt;
2151
2152 const DELETE: u32 = 0x0001_0000;
2153 const GENERIC_READ: u32 = 0x8000_0000;
2154 const FILE_SHARE_READ: u32 = 0x1;
2155 const FILE_SHARE_WRITE: u32 = 0x2;
2156 const FILE_SHARE_DELETE: u32 = 0x4;
2157 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2158 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2159
2160 let mut options = OpenOptions::new();
2166 options
2167 .read(true)
2168 .access_mode(GENERIC_READ | DELETE)
2169 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
2170 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
2171 let parent = parent.try_clone()?.into_std_file();
2172 let directory = cap_primitives::fs::open(&parent, Path::new(name), &options)?;
2173 let file_type = directory.metadata()?.file_type();
2174 if file_type.is_symlink_dir() || !file_type.is_dir() {
2175 Err(io::Error::other(
2176 "tree staging entry is not a real directory",
2177 ))
2178 } else {
2179 Ok(Dir::from_std_file(directory))
2180 }
2181}
2182
2183struct CommitStagingError {
2184 phase: FilesystemWritePhase,
2185 commit_certainty: CommitCertainty,
2186 source: io::Error,
2187}
2188
2189fn commit_staging(
2190 parent: &Dir,
2191 staging_file: &cap_std::fs::File,
2192 staging_name: &std::ffi::OsStr,
2193 target_name: &std::ffi::OsStr,
2194 policy: FilesystemMergePolicy,
2195) -> Result<(), CommitStagingError> {
2196 let result = match policy {
2197 FilesystemMergePolicy::WriteNewTree => {
2198 unreachable!("new-tree write commits a staging directory")
2199 }
2200 FilesystemMergePolicy::MergeCreateOnly => {
2201 commit_create_only(parent, staging_file, staging_name, target_name)
2202 }
2203 FilesystemMergePolicy::MergeReplaceExactFiles => {
2204 commit_replace_exact(parent, staging_file, staging_name, target_name)
2205 }
2206 };
2207 if let Err(source) = result {
2208 return Err(CommitStagingError {
2209 phase: FilesystemWritePhase::Commit,
2210 commit_certainty: observe_commit_certainty(
2211 parent,
2212 staging_file,
2213 staging_name,
2214 target_name,
2215 ),
2216 source,
2217 });
2218 }
2219
2220 Ok(())
2221}
2222
2223const fn merge_policy_supported(policy: FilesystemMergePolicy) -> bool {
2224 match policy {
2225 FilesystemMergePolicy::WriteNewTree | FilesystemMergePolicy::MergeCreateOnly => {
2226 cfg!(any(
2227 target_os = "linux",
2228 target_os = "android",
2229 target_os = "macos",
2230 target_os = "ios",
2231 windows
2232 ))
2233 }
2234 FilesystemMergePolicy::MergeReplaceExactFiles => true,
2235 }
2236}
2237
2238fn merge_policy_supported_for_plan(
2239 anchor: &DestinationAnchor,
2240 files: &[PlannedFile<'_>],
2241 policy: FilesystemMergePolicy,
2242) -> bool {
2243 if !merge_policy_supported_on(&anchor.directory, policy) {
2244 return false;
2245 }
2246 if !anchor.missing.is_empty() {
2247 return true;
2248 }
2249 for file in files {
2250 let mut directory = match anchor.directory.try_clone() {
2251 Ok(directory) => directory,
2252 Err(_) => return false,
2253 };
2254 for component in Path::new(file.relative_path)
2255 .parent()
2256 .unwrap_or_else(|| Path::new(""))
2257 .components()
2258 {
2259 let Component::Normal(component) = component else {
2260 return false;
2261 };
2262 match directory.symlink_metadata(component) {
2263 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
2264 directory = match open_child_directory_nofollow(&directory, component) {
2265 Ok(directory) => directory,
2266 Err(_) => return false,
2267 };
2268 if !merge_policy_supported_on(&directory, policy) {
2269 return false;
2270 }
2271 }
2272 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
2273 _ => return false,
2274 }
2275 }
2276 }
2277 true
2278}
2279
2280#[cfg(any(target_os = "linux", target_os = "android"))]
2281fn merge_policy_supported_on(parent: &Dir, policy: FilesystemMergePolicy) -> bool {
2282 use std::os::fd::AsRawFd;
2283
2284 if policy == FilesystemMergePolicy::MergeReplaceExactFiles {
2285 return true;
2286 }
2287 const EXT4_SUPER_MAGIC: u64 = 0xef53;
2288 const XFS_SUPER_MAGIC: u64 = 0x5846_5342;
2289 const BTRFS_SUPER_MAGIC: u64 = 0x9123_683e;
2290 const TMPFS_MAGIC: u64 = 0x0102_1994;
2291 const OVERLAYFS_SUPER_MAGIC: u64 = 0x794c_7630;
2292 const F2FS_SUPER_MAGIC: u64 = 0xf2f5_2010;
2293 const ZFS_SUPER_MAGIC: u64 = 0x2fc1_2fc1;
2294 const RAMFS_MAGIC: u64 = 0x8584_58f6;
2295 let mut status = std::mem::MaybeUninit::<libc::statfs>::uninit();
2296 if unsafe { libc::fstatfs(parent.as_raw_fd(), status.as_mut_ptr()) } != 0 {
2297 return false;
2298 }
2299 matches!(
2300 unsafe { status.assume_init() }.f_type as u64,
2301 EXT4_SUPER_MAGIC
2302 | XFS_SUPER_MAGIC
2303 | BTRFS_SUPER_MAGIC
2304 | TMPFS_MAGIC
2305 | OVERLAYFS_SUPER_MAGIC
2306 | F2FS_SUPER_MAGIC
2307 | ZFS_SUPER_MAGIC
2308 | RAMFS_MAGIC
2309 )
2310}
2311
2312#[cfg(any(target_os = "macos", target_os = "ios"))]
2313fn merge_policy_supported_on(parent: &Dir, policy: FilesystemMergePolicy) -> bool {
2314 use std::os::fd::AsRawFd;
2315
2316 if policy == FilesystemMergePolicy::MergeReplaceExactFiles {
2317 return true;
2318 }
2319 let mut status = std::mem::MaybeUninit::<libc::statfs>::uninit();
2320 if unsafe { libc::fstatfs(parent.as_raw_fd(), status.as_mut_ptr()) } != 0 {
2321 return false;
2322 }
2323 let status = unsafe { status.assume_init() };
2324 let name = status
2325 .f_fstypename
2326 .iter()
2327 .copied()
2328 .take_while(|byte| *byte != 0)
2329 .map(|byte| byte as u8)
2330 .collect::<Vec<_>>();
2331 matches!(name.as_slice(), b"apfs" | b"hfs")
2332}
2333
2334#[cfg(windows)]
2335fn merge_policy_supported_on(parent: &Dir, _policy: FilesystemMergePolicy) -> bool {
2336 const FILE_SUPPORTS_POSIX_UNLINK_RENAME: u32 = 0x0000_0400;
2337 parent
2338 .try_clone()
2339 .map(Dir::into_std_file)
2340 .and_then(|directory| windows_volume_information(&directory))
2341 .is_ok_and(|(_, flags)| flags & FILE_SUPPORTS_POSIX_UNLINK_RENAME != 0)
2342}
2343
2344#[cfg(not(any(
2345 target_os = "linux",
2346 target_os = "android",
2347 target_os = "macos",
2348 target_os = "ios",
2349 windows
2350)))]
2351const fn merge_policy_supported_on(_parent: &Dir, policy: FilesystemMergePolicy) -> bool {
2352 policy == FilesystemMergePolicy::MergeReplaceExactFiles
2353}
2354
2355#[cfg(any(target_os = "linux", target_os = "android"))]
2356fn commit_create_only(
2357 parent: &Dir,
2358 _staging_file: &cap_std::fs::File,
2359 staging_name: &std::ffi::OsStr,
2360 target_name: &std::ffi::OsStr,
2361) -> io::Result<()> {
2362 commit_create_only_names(parent, staging_name, target_name)
2363}
2364
2365#[cfg(any(target_os = "linux", target_os = "android"))]
2366fn commit_new_tree(
2367 parent: &Dir,
2368 _staging_root: &Dir,
2369 staging_name: &std::ffi::OsStr,
2370 target_name: &std::ffi::OsStr,
2371) -> io::Result<()> {
2372 commit_create_only_names(parent, staging_name, target_name)
2373}
2374
2375#[cfg(any(target_os = "linux", target_os = "android"))]
2376fn commit_create_only_names(
2377 parent: &Dir,
2378 staging_name: &std::ffi::OsStr,
2379 target_name: &std::ffi::OsStr,
2380) -> io::Result<()> {
2381 use std::ffi::CString;
2382 use std::os::fd::AsRawFd;
2383 use std::os::unix::ffi::OsStrExt;
2384
2385 let staging_name = CString::new(staging_name.as_bytes())?;
2386 let target_name = CString::new(target_name.as_bytes())?;
2387 let result = unsafe {
2388 libc::syscall(
2389 libc::SYS_renameat2,
2390 parent.as_raw_fd(),
2391 staging_name.as_ptr(),
2392 parent.as_raw_fd(),
2393 target_name.as_ptr(),
2394 libc::RENAME_NOREPLACE,
2395 )
2396 };
2397 if result == 0 {
2398 Ok(())
2399 } else {
2400 Err(io::Error::last_os_error())
2401 }
2402}
2403
2404#[cfg(any(target_os = "macos", target_os = "ios"))]
2405fn commit_create_only(
2406 parent: &Dir,
2407 _staging_file: &cap_std::fs::File,
2408 staging_name: &std::ffi::OsStr,
2409 target_name: &std::ffi::OsStr,
2410) -> io::Result<()> {
2411 commit_create_only_names(parent, staging_name, target_name)
2412}
2413
2414#[cfg(any(target_os = "macos", target_os = "ios"))]
2415fn commit_new_tree(
2416 parent: &Dir,
2417 _staging_root: &Dir,
2418 staging_name: &std::ffi::OsStr,
2419 target_name: &std::ffi::OsStr,
2420) -> io::Result<()> {
2421 commit_create_only_names(parent, staging_name, target_name)
2422}
2423
2424#[cfg(any(target_os = "macos", target_os = "ios"))]
2425fn commit_create_only_names(
2426 parent: &Dir,
2427 staging_name: &std::ffi::OsStr,
2428 target_name: &std::ffi::OsStr,
2429) -> io::Result<()> {
2430 use std::ffi::CString;
2431 use std::os::fd::AsRawFd;
2432 use std::os::unix::ffi::OsStrExt;
2433
2434 let staging_name = CString::new(staging_name.as_bytes())?;
2435 let target_name = CString::new(target_name.as_bytes())?;
2436 let result = unsafe {
2437 libc::renameatx_np(
2438 parent.as_raw_fd(),
2439 staging_name.as_ptr(),
2440 parent.as_raw_fd(),
2441 target_name.as_ptr(),
2442 libc::RENAME_EXCL,
2443 )
2444 };
2445 if result == 0 {
2446 Ok(())
2447 } else {
2448 Err(io::Error::last_os_error())
2449 }
2450}
2451
2452#[cfg(windows)]
2453fn commit_create_only(
2454 parent: &Dir,
2455 staging_file: &cap_std::fs::File,
2456 _staging_name: &std::ffi::OsStr,
2457 target_name: &std::ffi::OsStr,
2458) -> io::Result<()> {
2459 commit_windows_file(parent, staging_file, target_name, false)
2460}
2461
2462#[cfg(windows)]
2463fn commit_new_tree(
2464 parent: &Dir,
2465 staging_root: &Dir,
2466 _staging_name: &std::ffi::OsStr,
2467 target_name: &std::ffi::OsStr,
2468) -> io::Result<()> {
2469 use std::os::windows::io::AsRawHandle;
2470
2471 let staging_root = staging_root.try_clone()?.into_std_file();
2472 commit_windows_handle(parent, staging_root.as_raw_handle(), target_name, false)
2473}
2474
2475#[cfg(not(any(
2476 target_os = "linux",
2477 target_os = "android",
2478 target_os = "macos",
2479 target_os = "ios",
2480 windows
2481)))]
2482fn commit_create_only(
2483 _parent: &Dir,
2484 _staging_file: &cap_std::fs::File,
2485 _staging_name: &std::ffi::OsStr,
2486 _target_name: &std::ffi::OsStr,
2487) -> io::Result<()> {
2488 Err(io::Error::new(
2489 io::ErrorKind::Unsupported,
2490 "atomic create-only write is unsupported",
2491 ))
2492}
2493
2494#[cfg(not(any(
2495 target_os = "linux",
2496 target_os = "android",
2497 target_os = "macos",
2498 target_os = "ios",
2499 windows
2500)))]
2501fn commit_new_tree(
2502 _parent: &Dir,
2503 _staging_root: &Dir,
2504 _staging_name: &std::ffi::OsStr,
2505 _target_name: &std::ffi::OsStr,
2506) -> io::Result<()> {
2507 Err(io::Error::new(
2508 io::ErrorKind::Unsupported,
2509 "atomic new-tree write is unsupported",
2510 ))
2511}
2512
2513#[cfg(not(windows))]
2514fn commit_replace_exact(
2515 parent: &Dir,
2516 _staging_file: &cap_std::fs::File,
2517 staging_name: &std::ffi::OsStr,
2518 target_name: &std::ffi::OsStr,
2519) -> io::Result<()> {
2520 parent.rename(staging_name, parent, target_name)
2521}
2522
2523#[cfg(windows)]
2524fn commit_replace_exact(
2525 parent: &Dir,
2526 staging_file: &cap_std::fs::File,
2527 _staging_name: &std::ffi::OsStr,
2528 target_name: &std::ffi::OsStr,
2529) -> io::Result<()> {
2530 commit_windows_file(parent, staging_file, target_name, true)
2531}
2532
2533#[cfg(windows)]
2534fn commit_windows_file(
2535 parent: &Dir,
2536 staging_file: &cap_std::fs::File,
2537 target_name: &std::ffi::OsStr,
2538 replace: bool,
2539) -> io::Result<()> {
2540 use std::os::windows::io::AsRawHandle;
2541
2542 commit_windows_handle(parent, staging_file.as_raw_handle(), target_name, replace)
2543}
2544
2545#[cfg(windows)]
2558fn commit_windows_handle(
2559 parent: &Dir,
2560 staging_handle: std::os::windows::io::RawHandle,
2561 target_name: &std::ffi::OsStr,
2562 replace: bool,
2563) -> io::Result<()> {
2564 use std::mem::{MaybeUninit, offset_of, size_of};
2565 use std::os::windows::ffi::OsStrExt;
2566 use std::os::windows::io::AsRawHandle;
2567 use windows_sys::Wdk::Storage::FileSystem::{
2568 FILE_RENAME_INFORMATION, FILE_RENAME_INFORMATION_0, FILE_RENAME_POSIX_SEMANTICS,
2569 FILE_RENAME_REPLACE_IF_EXISTS, FileRenameInformationEx, NtSetInformationFile,
2570 };
2571 use windows_sys::Win32::Foundation::RtlNtStatusToDosError;
2572 use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
2573
2574 const STATUS_SUCCESS: i32 = 0;
2575 let name = target_name.encode_wide().collect::<Vec<_>>();
2576 let name_bytes = name.len() * size_of::<u16>();
2577 let bytes = offset_of!(FILE_RENAME_INFORMATION, FileName) + name_bytes + size_of::<u16>();
2579 let words = bytes.div_ceil(size_of::<usize>());
2580 let mut buffer = vec![0usize; words];
2581 let info = buffer.as_mut_ptr().cast::<FILE_RENAME_INFORMATION>();
2582 let parent_directory = parent.try_clone()?.into_std_file();
2584 let mut status_block = MaybeUninit::<IO_STATUS_BLOCK>::uninit();
2585 let status = unsafe {
2586 (*info).Anonymous = FILE_RENAME_INFORMATION_0 {
2587 Flags: FILE_RENAME_POSIX_SEMANTICS
2588 | if replace {
2589 FILE_RENAME_REPLACE_IF_EXISTS
2590 } else {
2591 0
2592 },
2593 };
2594 (*info).RootDirectory = parent_directory.as_raw_handle().cast();
2595 (*info).FileNameLength = u32::try_from(name_bytes)
2596 .map_err(|_| io::Error::other("destination file name is too long"))?;
2597 std::ptr::copy_nonoverlapping(
2598 name.as_ptr(),
2599 std::ptr::addr_of_mut!((*info).FileName).cast::<u16>(),
2600 name.len(),
2601 );
2602 NtSetInformationFile(
2603 staging_handle.cast(),
2604 status_block.as_mut_ptr(),
2605 info.cast(),
2606 u32::try_from(bytes).map_err(|_| io::Error::other("rename request is too large"))?,
2607 FileRenameInformationEx,
2608 )
2609 };
2610 if status == STATUS_SUCCESS {
2611 Ok(())
2612 } else {
2613 let code = unsafe { RtlNtStatusToDosError(status) };
2616 Err(io::Error::from_raw_os_error(code as i32))
2617 }
2618}
2619
2620#[cfg(unix)]
2621fn commit_policy_unsupported(error: &io::Error) -> bool {
2622 error.kind() == io::ErrorKind::Unsupported
2623 || error.raw_os_error().is_some_and(|code| {
2624 matches!(code, libc::ENOSYS | libc::EINVAL) || code == libc::EOPNOTSUPP
2625 })
2626}
2627
2628#[cfg(windows)]
2634fn commit_policy_unsupported(error: &io::Error) -> bool {
2635 const ERROR_INVALID_FUNCTION: i32 = 1;
2636 const ERROR_NOT_SUPPORTED: i32 = 50;
2637
2638 error.kind() == io::ErrorKind::Unsupported
2639 || matches!(
2640 error.raw_os_error(),
2641 Some(ERROR_INVALID_FUNCTION | ERROR_NOT_SUPPORTED)
2642 )
2643}
2644
2645#[cfg(not(any(unix, windows)))]
2646fn commit_policy_unsupported(error: &io::Error) -> bool {
2647 error.kind() == io::ErrorKind::Unsupported
2648}
2649
2650fn observe_commit_certainty(
2651 parent: &Dir,
2652 staging_file: &cap_std::fs::File,
2653 staging_name: &std::ffi::OsStr,
2654 target_name: &std::ffi::OsStr,
2655) -> CommitCertainty {
2656 let expected = staging_file
2657 .try_clone()
2658 .and_then(|file| same_file::Handle::from_file(file.into_std()));
2659 let stage = parent
2660 .open(staging_name)
2661 .and_then(|file| same_file::Handle::from_file(file.into_std()));
2662 let target = parent
2663 .open(target_name)
2664 .and_then(|file| same_file::Handle::from_file(file.into_std()));
2665 match (expected, stage, target) {
2666 (Ok(expected), _, Ok(target)) if expected == target => CommitCertainty::Committed,
2667 (Ok(expected), Ok(stage), _) if expected == stage => CommitCertainty::NotCommitted,
2668 _ => CommitCertainty::Indeterminate,
2669 }
2670}
2671
2672fn observe_tree_commit_certainty(
2673 parent: &Dir,
2674 staging_root: &Dir,
2675 staging_name: &std::ffi::OsStr,
2676 target_name: &std::ffi::OsStr,
2677) -> CommitCertainty {
2678 let expected = staging_root
2679 .try_clone()
2680 .and_then(|directory| same_file::Handle::from_file(directory.into_std_file()));
2681 let stage = open_tree_staging_directory(parent, staging_name)
2682 .and_then(|directory| same_file::Handle::from_file(directory.into_std_file()));
2683 let target = open_tree_staging_directory(parent, target_name)
2684 .and_then(|directory| same_file::Handle::from_file(directory.into_std_file()));
2685 match (expected, stage, target) {
2686 (Ok(expected), _, Ok(target)) if expected == target => CommitCertainty::Committed,
2687 (Ok(expected), Ok(stage), _) if expected == stage => CommitCertainty::NotCommitted,
2688 _ => CommitCertainty::Indeterminate,
2689 }
2690}
2691
2692fn io_core_error(
2693 phase: FilesystemWritePhase,
2694 failed_target: Option<PathBuf>,
2695 staging_residue: Option<PathBuf>,
2696 commit_certainty: CommitCertainty,
2697 completed: Vec<usize>,
2698 source: io::Error,
2699) -> CoreError {
2700 CoreError {
2701 phase,
2702 failed_target,
2703 staging_residue,
2704 staging_residue_status: None,
2705 commit_certainty,
2706 completed,
2707 preflight_issues: None,
2708 source: FilesystemWriteErrorCause::Io(source),
2709 }
2710}
2711
2712fn staging_core_error(
2713 parent: &Dir,
2714 staging_file: &cap_std::fs::File,
2715 staging_name: &std::ffi::OsStr,
2716 mut error: CoreError,
2717) -> CoreError {
2718 error.staging_residue_status = Some(observe_captured_staging(
2719 parent,
2720 staging_file,
2721 staging_name,
2722 error.staging_residue.as_deref(),
2723 ));
2724 error
2725}
2726
2727fn tree_staging_error(
2728 parent: &Dir,
2729 staging_root: &Dir,
2730 staging_name: &std::ffi::OsStr,
2731 mut error: CoreError,
2732) -> CoreError {
2733 error.staging_residue_status = Some(observe_captured_tree_staging(
2734 parent,
2735 staging_root,
2736 staging_name,
2737 error.staging_residue.as_deref(),
2738 ));
2739 error
2740}
2741
2742fn observe_captured_staging(
2743 parent: &Dir,
2744 staging_file: &cap_std::fs::File,
2745 staging_name: &std::ffi::OsStr,
2746 ambient_path: Option<&Path>,
2747) -> StagingResidueStatus {
2748 match parent.symlink_metadata(staging_name) {
2749 Err(error) if error.kind() == io::ErrorKind::NotFound => StagingResidueStatus::Absent,
2750 Err(_) => StagingResidueStatus::Indeterminate,
2751 Ok(_) => {
2752 let Some(ambient_path) = ambient_path else {
2753 return StagingResidueStatus::Indeterminate;
2754 };
2755 let expected = staging_file
2756 .try_clone()
2757 .and_then(|file| same_file::Handle::from_file(file.into_std()));
2758 let captured = parent
2759 .open(staging_name)
2760 .and_then(|file| same_file::Handle::from_file(file.into_std()));
2761 let ambient = same_file::Handle::from_path(ambient_path);
2762 match (expected, captured, ambient) {
2763 (Ok(expected), Ok(captured), Ok(ambient))
2764 if expected == captured && expected == ambient =>
2765 {
2766 StagingResidueStatus::Present
2767 }
2768 _ => StagingResidueStatus::Indeterminate,
2769 }
2770 }
2771 }
2772}
2773
2774fn observe_captured_tree_staging(
2775 parent: &Dir,
2776 staging_root: &Dir,
2777 staging_name: &std::ffi::OsStr,
2778 ambient_path: Option<&Path>,
2779) -> StagingResidueStatus {
2780 match parent.symlink_metadata(staging_name) {
2781 Err(error) if error.kind() == io::ErrorKind::NotFound => StagingResidueStatus::Absent,
2782 Err(_) => StagingResidueStatus::Indeterminate,
2783 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
2784 let Some(ambient_path) = ambient_path else {
2785 return StagingResidueStatus::Indeterminate;
2786 };
2787 let expected = staging_root
2788 .try_clone()
2789 .and_then(|directory| same_file::Handle::from_file(directory.into_std_file()));
2790 let captured = open_tree_staging_directory(parent, staging_name)
2791 .and_then(|directory| same_file::Handle::from_file(directory.into_std_file()));
2792 let ambient = same_file::Handle::from_path(ambient_path);
2793 match (expected, captured, ambient) {
2794 (Ok(expected), Ok(captured), Ok(ambient))
2795 if expected == captured && expected == ambient =>
2796 {
2797 StagingResidueStatus::Present
2798 }
2799 _ => StagingResidueStatus::Indeterminate,
2800 }
2801 }
2802 Ok(_) => StagingResidueStatus::Indeterminate,
2803 }
2804}
2805
2806fn residue_status(staging: Option<&Path>) -> StagingResidueStatus {
2807 match staging {
2808 None => StagingResidueStatus::Absent,
2809 Some(path) => match std::fs::symlink_metadata(path) {
2810 Ok(_) => StagingResidueStatus::Present,
2811 Err(error) if error.kind() == io::ErrorKind::NotFound => StagingResidueStatus::Absent,
2812 Err(_) => StagingResidueStatus::Indeterminate,
2813 },
2814 }
2815}
2816
2817fn retained_residue(staging: Option<PathBuf>, status: StagingResidueStatus) -> Option<PathBuf> {
2818 (status != StagingResidueStatus::Absent)
2819 .then_some(staging)
2820 .flatten()
2821}
2822
2823#[cfg(test)]
2824mod tests {
2825 use super::*;
2826
2827 #[cfg(unix)]
2830 fn temp_path(directory: &tempfile::TempDir) -> PathBuf {
2831 std::fs::canonicalize(directory.path()).unwrap()
2832 }
2833
2834 #[cfg(not(unix))]
2837 fn temp_path(directory: &tempfile::TempDir) -> PathBuf {
2838 directory.path().to_owned()
2839 }
2840
2841 #[test]
2842 fn staging_handles_short_writes_and_reports_write_and_flush_faults() {
2843 let mut short = FaultWriter::new(2, None, false);
2844 write_staging(&mut short, b"complete bytes").unwrap();
2845 assert_eq!(short.bytes, b"complete bytes");
2846 assert!(short.flush_called);
2847
2848 let mut write_fault = FaultWriter::new(3, Some(6), false);
2849 let (phase, _) = write_staging(&mut write_fault, b"complete bytes").unwrap_err();
2850 assert_eq!(phase, FilesystemWritePhase::StagingWrite);
2851 assert_eq!(write_fault.bytes, b"comple");
2852
2853 let mut flush_fault = FaultWriter::new(usize::MAX, None, true);
2854 let (phase, _) = write_staging(&mut flush_fault, b"complete bytes").unwrap_err();
2855 assert_eq!(phase, FilesystemWritePhase::StagingFlush);
2856 assert_eq!(flush_fault.bytes, b"complete bytes");
2857 }
2858
2859 #[test]
2860 fn later_commit_fault_retains_ordered_committed_file_progress() {
2861 let directory = tempfile::tempdir().unwrap();
2862 let destination = temp_path(&directory).join("written");
2863 let files = [
2864 PlannedFile {
2865 relative_path: Path::new("a.txt"),
2866 bytes: b"a",
2867 },
2868 PlannedFile {
2869 relative_path: Path::new("b.txt"),
2870 bytes: b"b",
2871 },
2872 ];
2873
2874 let core_error = write_files_before_commit(
2875 &files,
2876 &destination,
2877 FilesystemMergePolicy::MergeCreateOnly,
2878 |scope, _, staging| {
2879 if scope == CommitScope::PlannedFile(1) {
2880 std::fs::remove_file(staging).unwrap();
2881 }
2882 },
2883 )
2884 .unwrap_err();
2885 let error = pack_extraction_error(
2886 &files,
2887 &destination,
2888 FilesystemMergePolicy::MergeCreateOnly,
2889 core_error,
2890 );
2891
2892 assert_eq!(error.phase(), FilesystemWritePhase::Commit, "{error:?}");
2893 assert_eq!(
2894 error.failed_target(),
2895 Some(destination.join("b.txt").as_path()),
2896 "{error:?}"
2897 );
2898 assert_eq!(error.commit_certainty(), CommitCertainty::Indeterminate);
2899 assert_eq!(error.progress().completed()[0].relative_path(), "a.txt");
2900 assert_eq!(error.staging_residue_status(), StagingResidueStatus::Absent);
2901 assert!(
2902 matches!(error.cause(), FilesystemWriteErrorCause::Io(_)),
2903 "{error:?}"
2904 );
2905 #[cfg(not(windows))]
2911 assert!(
2912 matches!(
2913 error.cause(),
2914 FilesystemWriteErrorCause::Io(source)
2915 if source.kind() == io::ErrorKind::NotFound
2916 ),
2917 "{error:?}"
2918 );
2919 assert_eq!(std::fs::read(destination.join("a.txt")).unwrap(), b"a");
2920 assert!(!destination.join("b.txt").exists());
2921 }
2922
2923 #[test]
2924 fn new_tree_commit_race_reports_when_the_staged_root_was_committed() {
2925 let directory = tempfile::tempdir().unwrap();
2926 let destination = temp_path(&directory).join("written");
2927 let files = [PlannedFile {
2928 relative_path: Path::new("nested/file.txt"),
2929 bytes: b"complete",
2930 }];
2931
2932 let core_error = write_files_before_commit(
2933 &files,
2934 &destination,
2935 FilesystemMergePolicy::WriteNewTree,
2936 |_, target, staging| std::fs::rename(staging, target).unwrap(),
2937 )
2938 .unwrap_err();
2939 let error = pack_extraction_error(
2940 &files,
2941 &destination,
2942 FilesystemMergePolicy::WriteNewTree,
2943 core_error,
2944 );
2945
2946 assert_eq!(error.phase(), FilesystemWritePhase::Commit);
2947 assert_eq!(error.failed_target(), Some(destination.as_path()));
2948 assert_eq!(error.commit_certainty(), CommitCertainty::Committed);
2949 assert_eq!(
2950 error.progress().completed()[0].relative_path(),
2951 "nested/file.txt"
2952 );
2953 assert_eq!(error.staging_residue_status(), StagingResidueStatus::Absent);
2954 assert_eq!(
2955 std::fs::read(destination.join("nested/file.txt")).unwrap(),
2956 b"complete"
2957 );
2958 }
2959
2960 #[test]
2961 fn new_tree_target_race_preserves_the_complete_staging_residue() {
2962 let directory = tempfile::tempdir().unwrap();
2963 let destination = temp_path(&directory).join("written");
2964 let files = [PlannedFile {
2965 relative_path: Path::new("nested/file.txt"),
2966 bytes: b"complete",
2967 }];
2968
2969 let core_error = write_files_before_commit(
2970 &files,
2971 &destination,
2972 FilesystemMergePolicy::WriteNewTree,
2973 |_, target, staging| {
2974 assert_eq!(staging.parent(), target.parent());
2975 assert!(!target.exists());
2976 assert_eq!(
2977 std::fs::read(staging.join("nested/file.txt")).unwrap(),
2978 b"complete"
2979 );
2980 std::fs::create_dir(target).unwrap();
2981 },
2982 )
2983 .unwrap_err();
2984 let error = pack_extraction_error(
2985 &files,
2986 &destination,
2987 FilesystemMergePolicy::WriteNewTree,
2988 core_error,
2989 );
2990
2991 assert_eq!(error.phase(), FilesystemWritePhase::Commit);
2992 assert_eq!(error.failed_target(), Some(destination.as_path()));
2993 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
2994 assert_eq!(
2995 error.staging_residue_status(),
2996 StagingResidueStatus::Present
2997 );
2998 let staging = error.staging_residue().unwrap();
2999 assert_eq!(staging.parent(), destination.parent());
3000 assert_eq!(
3001 std::fs::read(staging.join("nested/file.txt")).unwrap(),
3002 b"complete"
3003 );
3004 assert!(std::fs::read_dir(&destination).unwrap().next().is_none());
3005 }
3006
3007 #[test]
3008 fn new_tree_vanished_staging_reports_indeterminate_commit() {
3009 let directory = tempfile::tempdir().unwrap();
3010 let destination = temp_path(&directory).join("written");
3011 let files = [PlannedFile {
3012 relative_path: Path::new("file.txt"),
3013 bytes: b"complete",
3014 }];
3015
3016 let core_error = write_files_before_commit(
3017 &files,
3018 &destination,
3019 FilesystemMergePolicy::WriteNewTree,
3020 |_, _, staging| std::fs::remove_dir_all(staging).unwrap(),
3021 )
3022 .unwrap_err();
3023 let error = pack_extraction_error(
3024 &files,
3025 &destination,
3026 FilesystemMergePolicy::WriteNewTree,
3027 core_error,
3028 );
3029
3030 assert_eq!(error.phase(), FilesystemWritePhase::Commit);
3031 assert_eq!(error.failed_target(), Some(destination.as_path()));
3032 assert_eq!(error.commit_certainty(), CommitCertainty::Indeterminate);
3033 assert_eq!(error.staging_residue_status(), StagingResidueStatus::Absent);
3034 assert_eq!(error.staging_residue(), None);
3035 assert!(!destination.exists());
3036 }
3037
3038 #[test]
3039 fn new_tree_unsupported_commit_remains_typed_without_merge_fallback() {
3040 let directory = tempfile::tempdir().unwrap();
3041 let destination = temp_path(&directory).join("written");
3042 let files = [PlannedFile {
3043 relative_path: Path::new("file.txt"),
3044 bytes: b"complete",
3045 }];
3046
3047 let core_error = write_files_with_faults(
3048 &files,
3049 &destination,
3050 FilesystemMergePolicy::WriteNewTree,
3051 WriteFaults {
3052 new_tree_commit_unsupported: true,
3053 ..WriteFaults::default()
3054 },
3055 |_, _, _| {},
3056 )
3057 .unwrap_err();
3058 let error = pack_extraction_error(
3059 &files,
3060 &destination,
3061 FilesystemMergePolicy::WriteNewTree,
3062 core_error,
3063 );
3064
3065 assert_eq!(error.phase(), FilesystemWritePhase::Commit);
3066 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
3067 assert!(matches!(
3068 error.cause(),
3069 FilesystemWriteErrorCause::UnsupportedPolicy(FilesystemMergePolicy::WriteNewTree)
3070 ));
3071 assert_eq!(
3072 error.staging_residue_status(),
3073 StagingResidueStatus::Present
3074 );
3075 assert!(!destination.exists());
3076 }
3077
3078 #[test]
3079 fn new_tree_unsupported_policy_is_rejected_before_staging() {
3080 let directory = tempfile::tempdir().unwrap();
3081 let root = temp_path(&directory);
3082 let destination = root.join("written");
3083 let files = [PlannedFile {
3084 relative_path: Path::new("file.txt"),
3085 bytes: b"complete",
3086 }];
3087
3088 let core_error = write_files_with_faults(
3089 &files,
3090 &destination,
3091 FilesystemMergePolicy::WriteNewTree,
3092 WriteFaults {
3093 new_tree_policy_unsupported: true,
3094 ..WriteFaults::default()
3095 },
3096 |_, _, _| {},
3097 )
3098 .unwrap_err();
3099 let error = pack_extraction_error(
3100 &files,
3101 &destination,
3102 FilesystemMergePolicy::WriteNewTree,
3103 core_error,
3104 );
3105
3106 assert_eq!(error.phase(), FilesystemWritePhase::Policy);
3107 assert_eq!(error.failed_target(), None);
3108 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
3109 assert!(matches!(
3110 error.cause(),
3111 FilesystemWriteErrorCause::UnsupportedPolicy(FilesystemMergePolicy::WriteNewTree)
3112 ));
3113 assert_eq!(error.staging_residue_status(), StagingResidueStatus::Absent);
3114 assert!(std::fs::read_dir(root).unwrap().next().is_none());
3115 }
3116
3117 #[test]
3118 fn new_tree_staging_open_fault_reports_cleanup_and_residue_truthfully() {
3119 for (cleanup_fault, phase, residue_status) in [
3120 (
3121 false,
3122 FilesystemWritePhase::StagingCreate,
3123 StagingResidueStatus::Absent,
3124 ),
3125 (
3126 true,
3127 FilesystemWritePhase::StagingCleanup,
3128 StagingResidueStatus::Indeterminate,
3129 ),
3130 ] {
3131 let directory = tempfile::tempdir().unwrap();
3132 let destination = temp_path(&directory).join("written");
3133 let files = [PlannedFile {
3134 relative_path: Path::new("file.txt"),
3135 bytes: b"complete",
3136 }];
3137
3138 let core_error = write_files_with_faults(
3139 &files,
3140 &destination,
3141 FilesystemMergePolicy::WriteNewTree,
3142 WriteFaults {
3143 tree_staging_open_fault: true,
3144 tree_staging_cleanup_fault: cleanup_fault,
3145 ..WriteFaults::default()
3146 },
3147 |_, _, _| {},
3148 )
3149 .unwrap_err();
3150 let error = pack_extraction_error(
3151 &files,
3152 &destination,
3153 FilesystemMergePolicy::WriteNewTree,
3154 core_error,
3155 );
3156
3157 assert_eq!(error.phase(), phase);
3158 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
3159 assert_eq!(error.staging_residue_status(), residue_status);
3160 assert_eq!(error.staging_residue().is_some(), cleanup_fault);
3161 assert!(!destination.exists());
3162 }
3163 }
3164
3165 #[cfg(unix)]
3166 #[test]
3167 fn target_symlink_race_is_rejected_without_writing_through_the_link() {
3168 use std::os::unix::fs::symlink;
3169
3170 let directory = tempfile::tempdir().unwrap();
3171 let root = temp_path(&directory);
3172 let destination = root.join("written");
3173 let outside = root.join("outside.txt");
3174 std::fs::write(&outside, b"outside").unwrap();
3175 let files = [PlannedFile {
3176 relative_path: Path::new("target.txt"),
3177 bytes: b"planned",
3178 }];
3179
3180 let error = write_files_before_commit(
3181 &files,
3182 &destination,
3183 FilesystemMergePolicy::MergeReplaceExactFiles,
3184 |_, target, _| symlink(&outside, target).unwrap(),
3185 )
3186 .unwrap_err();
3187
3188 assert_eq!(error.phase, FilesystemWritePhase::Commit);
3189 assert_eq!(error.commit_certainty, CommitCertainty::NotCommitted);
3190 assert!(error.completed.is_empty());
3191 assert_eq!(std::fs::read(&outside).unwrap(), b"outside");
3192 }
3193
3194 #[cfg(unix)]
3195 #[test]
3196 fn ancestor_symlink_race_keeps_staging_confined_and_reports_indeterminate_residue() {
3197 use std::os::unix::fs::symlink;
3198
3199 let directory = tempfile::tempdir().unwrap();
3200 let root = temp_path(&directory);
3201 let destination = root.join("written");
3202 let displaced = root.join("displaced");
3203 let outside = root.join("outside");
3204 std::fs::create_dir(&outside).unwrap();
3205 let files = [PlannedFile {
3206 relative_path: Path::new("nested/target.txt"),
3207 bytes: b"planned",
3208 }];
3209
3210 let core_error = write_files_before_commit(
3211 &files,
3212 &destination,
3213 FilesystemMergePolicy::MergeReplaceExactFiles,
3214 |_, target, _| {
3215 std::fs::rename(target.parent().unwrap(), &displaced).unwrap();
3216 symlink(&outside, target.parent().unwrap()).unwrap();
3217 },
3218 )
3219 .unwrap_err();
3220 let error = pack_extraction_error(
3221 &files,
3222 &destination,
3223 FilesystemMergePolicy::MergeReplaceExactFiles,
3224 core_error,
3225 );
3226
3227 assert_eq!(error.phase(), FilesystemWritePhase::Commit);
3228 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
3229 assert_eq!(
3230 error.staging_residue_status(),
3231 StagingResidueStatus::Indeterminate
3232 );
3233 assert!(error.staging_residue().is_some());
3234 assert!(error.progress().completed().is_empty());
3235 assert!(!outside.join("target.txt").exists());
3236 assert!(std::fs::read_dir(&displaced).unwrap().next().is_some());
3237 }
3238
3239 struct FaultWriter {
3240 bytes: Vec<u8>,
3241 maximum_write: usize,
3242 fail_after: Option<usize>,
3243 fail_flush: bool,
3244 flush_called: bool,
3245 }
3246
3247 impl FaultWriter {
3248 fn new(maximum_write: usize, fail_after: Option<usize>, fail_flush: bool) -> Self {
3249 Self {
3250 bytes: Vec::new(),
3251 maximum_write,
3252 fail_after,
3253 fail_flush,
3254 flush_called: false,
3255 }
3256 }
3257 }
3258
3259 impl Write for FaultWriter {
3260 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
3261 if self.fail_after == Some(self.bytes.len()) {
3262 return Err(io::Error::other("scripted write fault"));
3263 }
3264 let before_failure = self
3265 .fail_after
3266 .map_or(usize::MAX, |limit| limit - self.bytes.len());
3267 let written = buffer.len().min(self.maximum_write).min(before_failure);
3268 self.bytes.extend_from_slice(&buffer[..written]);
3269 Ok(written)
3270 }
3271
3272 fn flush(&mut self) -> io::Result<()> {
3273 self.flush_called = true;
3274 if self.fail_flush {
3275 Err(io::Error::other("scripted flush fault"))
3276 } else {
3277 Ok(())
3278 }
3279 }
3280 }
3281}