Skip to main content

typst_pack/
filesystem_write.rs

1//! Concrete filesystem writing for Pack Extraction Plans and Compilation Results.
2
3use 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/// An explicit policy for writing planned files to the filesystem.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum FilesystemMergePolicy {
24    /// Write a complete plan at an absent destination through one root commit.
25    WriteNewTree,
26    /// Create every planned file and reject any existing planned target.
27    MergeCreateOnly,
28    /// Create missing planned files and atomically replace existing regular files.
29    MergeReplaceExactFiles,
30}
31
32/// The filesystem phase reached by a plan write attempt.
33#[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/// A destination entry kind relevant to merge preflight.
47#[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/// One safely detectable issue found before filesystem writes begin.
67#[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/// The concrete cause retained by a failed filesystem plan write.
178#[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    // The fault this injects is a Unix symlink race. The Windows analogue is a
288    // directory junction swapped in for an ancestor, which needs a reparse
289    // point rather than a symlink; that fault is not implemented, so the
290    // ancestor race has no Windows fuzz coverage.
291    #[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
358/// Writes a Pack Extraction Plan under one explicit filesystem policy.
359///
360/// [`FilesystemMergePolicy::WriteNewTree`] requires an absent destination,
361/// stages the complete plan in a sibling directory, and exposes it through one
362/// root commit where supported. Unsupported guarantees are returned as
363/// [`FilesystemWriteErrorCause::UnsupportedPolicy`] rather than
364/// weakened to a merge. Errors describe visibility and staging residue; the
365/// adapter makes no crash-durability guarantee.
366pub 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/// One independently detectable issue before Compilation Output Artifact write.
416#[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/// A failure while writing Compilation Output Artifacts to caller-selected paths.
442#[derive(Debug)]
443pub struct CompilationArtifactPathWriteError {
444    kind: CompilationArtifactPathWriteErrorKind,
445}
446
447impl CompilationArtifactPathWriteError {
448    /// Every independently detectable pre-write issue.
449    pub fn issues(&self) -> Option<&[CompilationArtifactWriteIssue]> {
450        match &self.kind {
451            CompilationArtifactPathWriteErrorKind::Issues(issues) => Some(issues),
452            _ => None,
453        }
454    }
455
456    /// The concrete filesystem write failure, when write was attempted.
457    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/// A failure to derive one filesystem write root from output paths.
503#[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
523/// Resolves output paths into one existing filesystem root and relative targets.
524///
525/// Parent directories are resolved using native filesystem canonicalization,
526/// while each output file name may remain absent for later write.
527pub 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
583/// Writes a succeeded Compilation Result through caller-selected
584/// destination-relative filesystem paths.
585///
586/// This concrete adapter supports platform paths used by CLI output templates
587/// while consuming artifact roles, canonical order, and exact bytes directly
588/// from the Compilation Result.
589pub 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    // These flags stay raw rather than becoming `cap_fs_ext`'s `maybe_dir` and
1890    // `follow`, which the reader traversal uses. `maybe_dir` also clears
1891    // `FILE_SHARE_DELETE`, so that a directory cannot be renamed underneath a
1892    // sandboxed lookup; this handle only interrogates the volume and shares
1893    // everything, so it must not pin an ancestor of the caller's destination.
1894    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    // These flags stay raw rather than becoming `cap_fs_ext`'s `maybe_dir` and
2161    // `follow`, which the reader traversal uses. `maybe_dir` also clears
2162    // `FILE_SHARE_DELETE`, and this handle is opened precisely so that the
2163    // staging tree stays renamable and removable while it is held: the commit
2164    // and the residue cleanup both need that.
2165    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// The commit resolves a simple target name against the captured parent
2546// directory handle, the way the unix commits resolve one against a directory
2547// descriptor. `SetFileInformationByHandle` cannot express that: it rewrites
2548// `FileName` into a fully qualified NT path before it reaches the kernel,
2549// resolving a relative name against the process working directory, and passes
2550// `RootDirectory` through unchanged. A null root then renames the staging file
2551// toward the working directory, off-volume in the general case
2552// (`ERROR_NOT_SAME_DEVICE`), and a captured root pairs a handle with an
2553// absolute name, which the kernel rejects (`ERROR_INVALID_PARAMETER`). The
2554// rename request therefore goes to `NtSetInformationFile`, which performs no
2555// such rewrite. `FileRenameInformationEx` is the same operation the volume
2556// advertised through `FILE_SUPPORTS_POSIX_UNLINK_RENAME` before staging began.
2557#[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    // FileNameLength excludes the trailing NUL, but the request buffer includes it.
2578    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    // The captured directory handle has to outlive the request.
2583    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        // The same translation the Win32 wrapper applies before it reports a
2614        // failed request through `GetLastError`.
2615        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// `ERROR_INVALID_PARAMETER` is deliberately absent. The volume advertises
2629// `FILE_SUPPORTS_POSIX_UNLINK_RENAME` before staging begins, so a commit the
2630// filesystem cannot perform reports the request as unsupported rather than as
2631// malformed; reading a rejected parameter as an unsupported policy hid a
2632// malformed rename request behind a typed refusal.
2633#[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    // macOS temp paths can start with `/var`, a symlink to `/private/var`, so
2828    // a unix destination is resolved before it reaches the write preflight.
2829    #[cfg(unix)]
2830    fn temp_path(directory: &tempfile::TempDir) -> PathBuf {
2831        std::fs::canonicalize(directory.path()).unwrap()
2832    }
2833
2834    // Windows temp paths are not reached through a symlink, and canonicalizing
2835    // one yields a `\\?\` verbatim path that no caller would supply.
2836    #[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        // The fault removes the staging name. The unix commit renames that
2906        // name and reports the miss as `ENOENT`; the Windows commit renames
2907        // the still-open staging handle, which the kernel refuses with a code
2908        // of its own. What this test pins down is the commit failing with the
2909        // progress it kept, not how a platform spells the refusal.
2910        #[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}