Skip to main content

typst_pack/
fs_packages.rs

1//! The package-reading half of Pack Assembly for the reference filesystem
2//! Pack Assembler.
3//!
4//! Reading is resume-driven: the core reports the exact specifications its
5//! representative request read and was not given, and the adapter obtains each
6//! of them through the configured Package Authority — local package
7//! directories, then the package cache, then a download unless creation is
8//! offline or the build has no egress compiled in to download with.
9
10#[cfg(feature = "egress")]
11use std::io::BufReader;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14#[cfg(feature = "egress")]
15use std::sync::Arc;
16use std::sync::Mutex;
17#[cfg(feature = "egress")]
18use std::sync::OnceLock;
19
20#[cfg(feature = "egress")]
21use typst::diag::PackageError;
22use typst::foundations::Bytes;
23use typst::syntax::package::PackageSpec;
24use typst_kit::packages::FsPackages;
25
26use crate::error_display::format_error_list;
27use crate::fs_traversal::{self, TraversalPolicy, UnsupportedEntry};
28use crate::limits::{LimitError, Limits, ResourceKind};
29use crate::package_catalog::{PackageTree, PackageTreeError};
30use crate::package_failure::{PackageReadFailure, PackageReadFailureReason};
31#[cfg(feature = "egress")]
32use crate::{
33    PackageArchiveReadError, PackageExpansionLimits, PackageReadError, expand_package_archive,
34    package_archive_url, read_package_archive,
35};
36
37#[cfg(feature = "egress")]
38const USER_AGENT: &str = concat!("typst-pack/", env!("CARGO_PKG_VERSION"));
39
40#[cfg(all(feature = "_test-package-download-probe", debug_assertions))]
41const PACKAGE_DOWNLOAD_PROBE_ENV: &str = "TYPST_PACK_TEST_PACKAGE_DOWNLOAD_PROBE";
42
43/// A resource bounded during filesystem Package Tree reading.
44pub type FilesystemPackageResource = ResourceKind<1>;
45
46#[allow(non_upper_case_globals)]
47impl ResourceKind<1> {
48    pub const VisitedEntries: Self = Self::new(0);
49    pub const SelectedFiles: Self = Self::new(1);
50    pub const SelectedFileBytes: Self = Self::new(2);
51    pub const PackageTreeBytes: Self = Self::new(3);
52}
53
54/// A filesystem package exceeded a mandatory reading ceiling.
55pub type FilesystemPackageLimitError = LimitError<FilesystemPackageResource>;
56
57/// Mandatory finite resource ceilings for filesystem Package Tree reading.
58pub type FilesystemPackageLimits = Limits<FilesystemPackageResource>;
59
60impl Limits<FilesystemPackageResource> {
61    #[track_caller]
62    pub fn new(
63        visited_entries: u64,
64        selected_files: u64,
65        selected_file_bytes: u64,
66        package_tree_bytes: u64,
67    ) -> Self {
68        Self::from_ceilings([
69            visited_entries,
70            selected_files,
71            selected_file_bytes,
72            package_tree_bytes,
73            0,
74            0,
75            0,
76        ])
77        .assert_probe_resources([
78            FilesystemPackageResource::VisitedEntries,
79            FilesystemPackageResource::SelectedFiles,
80            FilesystemPackageResource::SelectedFileBytes,
81            FilesystemPackageResource::PackageTreeBytes,
82        ])
83    }
84
85    /// The first-party limits for package trees read from filesystems.
86    pub const fn reference_v1() -> Self {
87        Self::from_ceilings([
88            100_000,
89            50_000,
90            64 * 1024 * 1024,
91            512 * 1024 * 1024,
92            0,
93            0,
94            0,
95        ])
96    }
97
98    pub const fn visited_entries(&self) -> u64 {
99        self.ceilings[0]
100    }
101
102    pub const fn selected_files(&self) -> u64 {
103        self.ceilings[1]
104    }
105
106    pub const fn selected_file_bytes(&self) -> u64 {
107        self.ceilings[2]
108    }
109
110    pub const fn package_tree_bytes(&self) -> u64 {
111        self.ceilings[3]
112    }
113}
114
115/// The kind of a filesystem entry that cannot become a package file.
116#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117#[non_exhaustive]
118pub enum FilesystemPackageEntryKind {
119    Socket,
120    Fifo,
121    BlockDevice,
122    CharacterDevice,
123    Unknown,
124}
125
126/// The filesystem operation that failed while reading a Package Tree.
127#[derive(Debug, Clone, Copy, Eq, PartialEq)]
128#[non_exhaustive]
129pub enum FilesystemPackageOperation {
130    InspectRoot,
131    SurveyEntry,
132    InspectSelectedFile,
133    ReadSelectedFile,
134}
135
136impl std::fmt::Display for FilesystemPackageOperation {
137    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        formatter.write_str(match self {
139            Self::InspectRoot => "inspect package root",
140            Self::SurveyEntry => "survey package entry",
141            Self::InspectSelectedFile => "inspect selected package file",
142            Self::ReadSelectedFile => "read selected package file",
143        })
144    }
145}
146
147/// One independently detectable filesystem Package Tree survey issue.
148#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
149#[non_exhaustive]
150pub enum FilesystemPackageIssue {
151    #[error("unsupported filesystem entry {path:?}: aliases cannot become package files")]
152    Alias { path: PathBuf },
153    #[error("unsupported filesystem entry {path:?} in the package tree")]
154    UnsupportedEntry {
155        path: PathBuf,
156        kind: FilesystemPackageEntryKind,
157    },
158    #[error("package path {path:?} is not valid UTF-8")]
159    UnrepresentablePath { path: PathBuf },
160    #[error("filesystem package root {path:?} is not a directory")]
161    RootNotDirectory { path: PathBuf },
162}
163
164impl FilesystemPackageIssue {
165    fn path(&self) -> &Path {
166        match self {
167            Self::Alias { path }
168            | Self::UnsupportedEntry { path, .. }
169            | Self::UnrepresentablePath { path }
170            | Self::RootNotDirectory { path } => path,
171        }
172    }
173
174    fn rank(&self) -> u8 {
175        match self {
176            Self::Alias { .. } => 0,
177            Self::UnsupportedEntry { .. } => 1,
178            Self::UnrepresentablePath { .. } => 2,
179            Self::RootNotDirectory { .. } => 3,
180        }
181    }
182}
183
184/// All safely detectable issues found by one filesystem package survey.
185#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
186#[error(
187    "filesystem package survey found {} issue(s){}",
188    .issues.len(),
189    format_error_list(.issues.as_slice())
190)]
191pub struct FilesystemPackageSurveyError {
192    issues: Vec<FilesystemPackageIssue>,
193}
194
195impl FilesystemPackageSurveyError {
196    pub fn issues(&self) -> &[FilesystemPackageIssue] {
197        &self.issues
198    }
199}
200
201/// A failure while reading a Package Tree from the filesystem.
202#[derive(Debug, thiserror::Error)]
203#[non_exhaustive]
204pub enum FilesystemPackageReadError {
205    #[error("failed to {operation} {path:?}: {source}")]
206    Io {
207        operation: FilesystemPackageOperation,
208        path: PathBuf,
209        #[source]
210        source: std::io::Error,
211    },
212    #[error(transparent)]
213    Survey(FilesystemPackageSurveyError),
214    #[error("filesystem package resource limit at {path:?}: {source}")]
215    Limit {
216        path: PathBuf,
217        source: FilesystemPackageLimitError,
218    },
219    #[error("selected filesystem entries do not form a Package Tree: {0}")]
220    PackageTree(#[source] PackageTreeError),
221}
222
223impl FilesystemPackageReadError {
224    fn io(
225        operation: FilesystemPackageOperation,
226        path: impl Into<PathBuf>,
227        source: std::io::Error,
228    ) -> Self {
229        Self::Io {
230            operation,
231            path: path.into(),
232            source,
233        }
234    }
235
236    fn limit(path: impl Into<PathBuf>, source: FilesystemPackageLimitError) -> Self {
237        Self::Limit {
238            path: path.into(),
239            source,
240        }
241    }
242}
243
244/// Reads every addressable regular file beneath one filesystem package root.
245pub fn read_filesystem_package(
246    root: impl AsRef<Path>,
247    limits: FilesystemPackageLimits,
248) -> Result<PackageTree, FilesystemPackageReadError> {
249    let root = root.as_ref();
250    let root_metadata = std::fs::symlink_metadata(root).map_err(|source| {
251        FilesystemPackageReadError::io(FilesystemPackageOperation::InspectRoot, root, source)
252    })?;
253    if root_metadata.file_type().is_symlink() {
254        return Err(FilesystemPackageReadError::Survey(
255            FilesystemPackageSurveyError {
256                issues: vec![FilesystemPackageIssue::Alias {
257                    path: root.to_owned(),
258                }],
259            },
260        ));
261    }
262    if !root_metadata.is_dir() {
263        return Err(FilesystemPackageReadError::Survey(
264            FilesystemPackageSurveyError {
265                issues: vec![FilesystemPackageIssue::RootNotDirectory {
266                    path: root.to_owned(),
267                }],
268            },
269        ));
270    }
271
272    let mut visited_entries = 0u64;
273    let mut selected_files = 0u64;
274    let mut declared_total = 0u64;
275    let mut selected = Vec::new();
276    let mut issues = Vec::new();
277    let mut deferred_limit = None;
278    for entry in walkdir::WalkDir::new(root).follow_links(false) {
279        let entry = entry.map_err(|error| {
280            let path = error.path().unwrap_or(root).to_owned();
281            let source = error
282                .into_io_error()
283                .unwrap_or_else(|| std::io::Error::other("filesystem traversal failed"));
284            FilesystemPackageReadError::io(FilesystemPackageOperation::SurveyEntry, path, source)
285        })?;
286        if entry.depth() == 0 {
287            continue;
288        }
289
290        visited_entries = checked_add(
291            visited_entries,
292            1,
293            FilesystemPackageResource::VisitedEntries,
294        )
295        .map_err(|source| FilesystemPackageReadError::limit(entry.path(), source))?;
296        check_limit(
297            FilesystemPackageResource::VisitedEntries,
298            limits.visited_entries(),
299            visited_entries,
300        )
301        .map_err(|source| FilesystemPackageReadError::limit(entry.path(), source))?;
302
303        let relative = entry
304            .path()
305            .strip_prefix(root)
306            .expect("walk remains beneath package root");
307        let Some(path) = slash_path(relative) else {
308            issues.push(FilesystemPackageIssue::UnrepresentablePath {
309                path: entry.path().to_owned(),
310            });
311            continue;
312        };
313        let file_type = entry.file_type();
314        if file_type.is_dir() {
315            continue;
316        }
317        if file_type.is_symlink() {
318            issues.push(FilesystemPackageIssue::Alias {
319                path: entry.path().to_owned(),
320            });
321            continue;
322        }
323        if !file_type.is_file() {
324            issues.push(FilesystemPackageIssue::UnsupportedEntry {
325                path: entry.path().to_owned(),
326                kind: UnsupportedEntry::of(&file_type).into(),
327            });
328            continue;
329        }
330
331        selected_files =
332            checked_add(selected_files, 1, FilesystemPackageResource::SelectedFiles)
333                .map_err(|source| FilesystemPackageReadError::limit(entry.path(), source))?;
334        if let Err(source) = check_limit(
335            FilesystemPackageResource::SelectedFiles,
336            limits.selected_files(),
337            selected_files,
338        ) {
339            deferred_limit
340                .get_or_insert_with(|| FilesystemPackageReadError::limit(entry.path(), source));
341            continue;
342        }
343        let metadata = entry.metadata().map_err(|error| {
344            let path = error.path().unwrap_or(entry.path()).to_owned();
345            let source = error.into_io_error().unwrap_or_else(|| {
346                std::io::Error::other("failed to inspect selected package file")
347            });
348            FilesystemPackageReadError::io(
349                FilesystemPackageOperation::InspectSelectedFile,
350                path,
351                source,
352            )
353        })?;
354        if let Err(source) = check_limit(
355            FilesystemPackageResource::SelectedFileBytes,
356            limits.selected_file_bytes(),
357            metadata.len(),
358        ) {
359            deferred_limit
360                .get_or_insert_with(|| FilesystemPackageReadError::limit(entry.path(), source));
361            continue;
362        }
363        declared_total = checked_add(
364            declared_total,
365            metadata.len(),
366            FilesystemPackageResource::PackageTreeBytes,
367        )
368        .map_err(|source| FilesystemPackageReadError::limit(entry.path(), source))?;
369        if let Err(source) = check_limit(
370            FilesystemPackageResource::PackageTreeBytes,
371            limits.package_tree_bytes(),
372            declared_total,
373        ) {
374            deferred_limit
375                .get_or_insert_with(|| FilesystemPackageReadError::limit(entry.path(), source));
376            continue;
377        }
378        selected.push((path, entry.path().to_owned()));
379    }
380
381    if !issues.is_empty() {
382        issues.sort_by(|left, right| {
383            left.path()
384                .cmp(right.path())
385                .then_with(|| left.rank().cmp(&right.rank()))
386        });
387        return Err(FilesystemPackageReadError::Survey(
388            FilesystemPackageSurveyError { issues },
389        ));
390    }
391    if let Some(error) = deferred_limit {
392        return Err(error);
393    }
394
395    selected.sort_by(|(left, _), (right, _)| left.cmp(right));
396    let mut actual_total = 0u64;
397    let mut entries = Vec::with_capacity(selected.len());
398    for (path, source) in selected {
399        let mut file = fs_traversal::open_without_following(&PackageTraversal, root, &source)?;
400        let bytes = read_bounded_package_file(
401            &mut file,
402            &source,
403            limits.selected_file_bytes(),
404            actual_total,
405            limits.package_tree_bytes(),
406        )?;
407        let observed = u64::try_from(bytes.len()).map_err(|_| {
408            FilesystemPackageReadError::limit(
409                &source,
410                FilesystemPackageLimitError::AccountingOverflow {
411                    resource: FilesystemPackageResource::PackageTreeBytes,
412                },
413            )
414        })?;
415        actual_total = checked_add(
416            actual_total,
417            observed,
418            FilesystemPackageResource::PackageTreeBytes,
419        )
420        .map_err(|source_error| FilesystemPackageReadError::limit(&source, source_error))?;
421        entries.push((path, bytes));
422    }
423
424    PackageTree::from_owned_entries(entries).map_err(FilesystemPackageReadError::PackageTree)
425}
426
427fn read_bounded_package_file(
428    mut reader: impl Read,
429    path: &Path,
430    selected_file_ceiling: u64,
431    total_before: u64,
432    package_tree_ceiling: u64,
433) -> Result<Vec<u8>, FilesystemPackageReadError> {
434    let total_allowance = package_tree_ceiling.saturating_sub(total_before);
435    let allowance = selected_file_ceiling.min(total_allowance);
436    let mut bytes = Vec::new();
437    reader
438        .by_ref()
439        .take(allowance + 1)
440        .read_to_end(&mut bytes)
441        .map_err(|error| {
442            FilesystemPackageReadError::io(
443                FilesystemPackageOperation::ReadSelectedFile,
444                path,
445                error,
446            )
447        })?;
448    let observed = u64::try_from(bytes.len()).map_err(|_| {
449        FilesystemPackageReadError::limit(
450            path,
451            FilesystemPackageLimitError::AccountingOverflow {
452                resource: FilesystemPackageResource::SelectedFileBytes,
453            },
454        )
455    })?;
456    check_limit(
457        FilesystemPackageResource::SelectedFileBytes,
458        selected_file_ceiling,
459        observed,
460    )
461    .map_err(|source| FilesystemPackageReadError::limit(path, source))?;
462    let total = checked_add(
463        total_before,
464        observed,
465        FilesystemPackageResource::PackageTreeBytes,
466    )
467    .map_err(|source| FilesystemPackageReadError::limit(path, source))?;
468    check_limit(
469        FilesystemPackageResource::PackageTreeBytes,
470        package_tree_ceiling,
471        total,
472    )
473    .map_err(|source| FilesystemPackageReadError::limit(path, source))?;
474    Ok(bytes)
475}
476
477/// The package reader's vocabulary for the shared symlink-refusing open.
478struct PackageTraversal;
479
480impl TraversalPolicy for PackageTraversal {
481    type Error = FilesystemPackageReadError;
482
483    const ROOT_INVARIANT: &'static str = "a selected package path remains beneath its root";
484
485    fn io(&self, path: &Path, source: std::io::Error) -> Self::Error {
486        FilesystemPackageReadError::io(FilesystemPackageOperation::ReadSelectedFile, path, source)
487    }
488
489    fn alias(&self, path: &Path) -> Self::Error {
490        alias_error(path)
491    }
492
493    fn unsupported_entry(&self, path: &Path, entry: UnsupportedEntry) -> Self::Error {
494        FilesystemPackageReadError::Survey(FilesystemPackageSurveyError {
495            issues: vec![FilesystemPackageIssue::UnsupportedEntry {
496                path: path.to_owned(),
497                kind: entry.into(),
498            }],
499        })
500    }
501}
502
503impl From<UnsupportedEntry> for FilesystemPackageEntryKind {
504    fn from(entry: UnsupportedEntry) -> Self {
505        match entry {
506            UnsupportedEntry::Socket => Self::Socket,
507            UnsupportedEntry::Fifo => Self::Fifo,
508            UnsupportedEntry::BlockDevice => Self::BlockDevice,
509            UnsupportedEntry::CharacterDevice => Self::CharacterDevice,
510            UnsupportedEntry::Unknown => Self::Unknown,
511        }
512    }
513}
514
515fn alias_error(path: &Path) -> FilesystemPackageReadError {
516    FilesystemPackageReadError::Survey(FilesystemPackageSurveyError {
517        issues: vec![FilesystemPackageIssue::Alias {
518            path: path.to_owned(),
519        }],
520    })
521}
522
523fn slash_path(path: &Path) -> Option<String> {
524    path.components()
525        .map(|component| component.as_os_str().to_str())
526        .collect::<Option<Vec<_>>>()
527        .map(|components| components.join("/"))
528}
529
530fn checked_add(
531    total: u64,
532    value: u64,
533    resource: FilesystemPackageResource,
534) -> Result<u64, FilesystemPackageLimitError> {
535    total
536        .checked_add(value)
537        .ok_or(FilesystemPackageLimitError::AccountingOverflow { resource })
538}
539
540fn check_limit(
541    resource: FilesystemPackageResource,
542    ceiling: u64,
543    observed: u64,
544) -> Result<(), FilesystemPackageLimitError> {
545    if observed > ceiling {
546        return Err(FilesystemPackageLimitError::exceeded(resource, ceiling));
547    }
548    Ok(())
549}
550
551/// A typed failure from the concrete filesystem Package Authority.
552///
553/// The stable Package Read Failure remains available through
554/// [`Self::failure`], while adapter and transformation failures retain their
555/// authoritative lower-module source.
556#[derive(Debug, thiserror::Error)]
557#[non_exhaustive]
558pub enum FilesystemPackageAuthorityReadError {
559    #[error(transparent)]
560    Unavailable(PackageReadFailure),
561    #[error("{failure}: {source}")]
562    Filesystem {
563        failure: PackageReadFailure,
564        #[source]
565        source: Box<FilesystemPackageReadError>,
566    },
567    #[cfg(feature = "egress")]
568    #[error("{failure}: {source}")]
569    RegistryUrl {
570        failure: PackageReadFailure,
571        #[source]
572        source: Box<PackageReadError>,
573    },
574    #[cfg(feature = "egress")]
575    #[error("{failure}: {source}")]
576    Download {
577        failure: PackageReadFailure,
578        #[source]
579        source: std::io::Error,
580    },
581    #[cfg(feature = "egress")]
582    #[error("{failure}: {source}")]
583    DownloadSize {
584        failure: PackageReadFailure,
585        #[source]
586        source: std::num::TryFromIntError,
587    },
588    #[cfg(feature = "egress")]
589    #[error("{failure}: {source}")]
590    ArchiveRead {
591        failure: PackageReadFailure,
592        #[source]
593        source: Box<PackageArchiveReadError>,
594    },
595    #[cfg(feature = "egress")]
596    #[error("{failure}: {source}")]
597    ArchiveExpansion {
598        failure: PackageReadFailure,
599        #[source]
600        source: Box<PackageReadError>,
601    },
602    #[cfg(feature = "egress")]
603    #[error("{failure}: {source}")]
604    Cache {
605        failure: PackageReadFailure,
606        #[source]
607        source: Box<PackageError>,
608    },
609}
610
611impl FilesystemPackageAuthorityReadError {
612    /// The stable exact-specification failure represented by this adapter error.
613    pub fn failure(&self) -> &PackageReadFailure {
614        match self {
615            Self::Unavailable(failure) | Self::Filesystem { failure, .. } => failure,
616            #[cfg(feature = "egress")]
617            Self::RegistryUrl { failure, .. }
618            | Self::Download { failure, .. }
619            | Self::DownloadSize { failure, .. }
620            | Self::ArchiveRead { failure, .. }
621            | Self::ArchiveExpansion { failure, .. }
622            | Self::Cache { failure, .. } => failure,
623        }
624    }
625}
626
627/// The concrete Package Authority used by the reference filesystem workflows.
628///
629/// Local package data, package cache, offline policy, and registry read
630/// remain explicit here rather than being fallback behavior in Pack Creation.
631#[derive(Debug)]
632pub struct FilesystemPackageAuthority {
633    data: Option<FsPackages>,
634    cache: Option<FsPackages>,
635    offline: bool,
636    source_limits: FilesystemPackageLimits,
637    #[cfg(feature = "egress")]
638    expansion_limits: PackageExpansionLimits,
639    #[cfg(feature = "egress")]
640    certificate: Option<PathBuf>,
641}
642
643impl FilesystemPackageAuthority {
644    /// Configures local and cache package directories plus offline policy.
645    pub fn new(
646        package_path: Option<&Path>,
647        package_cache_path: Option<&Path>,
648        offline: bool,
649    ) -> Self {
650        Self::with_limits(
651            package_path,
652            package_cache_path,
653            offline,
654            FilesystemPackageLimits::reference_v1(),
655            #[cfg(feature = "egress")]
656            PackageExpansionLimits::reference_v1(),
657        )
658    }
659
660    pub(crate) fn with_limits(
661        package_path: Option<&Path>,
662        package_cache_path: Option<&Path>,
663        offline: bool,
664        source_limits: FilesystemPackageLimits,
665        #[cfg(feature = "egress")] expansion_limits: PackageExpansionLimits,
666    ) -> Self {
667        let data = match package_path {
668            Some(path) => Some(FsPackages::new(path)),
669            None => FsPackages::system_data(),
670        };
671        let cache = match package_cache_path {
672            Some(path) => Some(FsPackages::new(path)),
673            None => FsPackages::system_cache(),
674        };
675        Self {
676            data,
677            cache,
678            offline,
679            source_limits,
680            #[cfg(feature = "egress")]
681            expansion_limits,
682            #[cfg(feature = "egress")]
683            certificate: None,
684        }
685    }
686
687    /// Configures a custom CA certificate for registry downloads.
688    #[cfg(feature = "egress")]
689    pub fn certificate(mut self, certificate: Option<PathBuf>) -> Self {
690        self.certificate = certificate;
691        self
692    }
693
694    /// Reads one exact validated tree and identifies its filesystem root
695    /// when the bytes came from or were written to one.
696    pub fn read(
697        &self,
698        spec: &PackageSpec,
699    ) -> Result<ReadPackage, FilesystemPackageAuthorityReadError> {
700        if let Some(read) = self.read_from(&self.data, spec)? {
701            return Ok(read);
702        }
703        if let Some(read) = self.read_from(&self.cache, spec)? {
704            return Ok(read);
705        }
706        if self.offline {
707            return Err(FilesystemPackageAuthorityReadError::Unavailable(not_found(
708                spec,
709            )));
710        }
711
712        #[cfg(feature = "egress")]
713        {
714            self.read_from_registry(spec)
715        }
716        #[cfg(not(feature = "egress"))]
717        {
718            Err(FilesystemPackageAuthorityReadError::Unavailable(not_found(
719                spec,
720            )))
721        }
722    }
723
724    fn read_from(
725        &self,
726        packages: &Option<FsPackages>,
727        spec: &PackageSpec,
728    ) -> Result<Option<ReadPackage>, FilesystemPackageAuthorityReadError> {
729        let Some(root) = packages.as_ref().and_then(|packages| packages.obtain(spec)) else {
730            return Ok(None);
731        };
732        let tree = read_filesystem_package(root.path(), self.source_limits).map_err(|source| {
733            let failure = other_failure(spec, source.to_string());
734            FilesystemPackageAuthorityReadError::Filesystem {
735                failure,
736                source: Box::new(source),
737            }
738        })?;
739        Ok(Some(ReadPackage {
740            tree,
741            root: Some(root.path().to_owned()),
742        }))
743    }
744
745    #[cfg(feature = "egress")]
746    fn read_from_registry(
747        &self,
748        spec: &PackageSpec,
749    ) -> Result<ReadPackage, FilesystemPackageAuthorityReadError> {
750        let url = package_archive_url(spec).map_err(|source| {
751            FilesystemPackageAuthorityReadError::RegistryUrl {
752                failure: not_found(spec),
753                source: Box::new(source),
754            }
755        })?;
756        let downloader = RustlsDownloader::new(USER_AGENT, self.certificate.clone());
757        use typst_kit::downloader::Downloader;
758        let (known_size, reader) = downloader.stream(spec, &url).map_err(|source| {
759            // Do not turn a bounded archive request into an unprofiled package
760            // index download merely to refine NotFound into VersionNotFound.
761            let failure = if source.kind() == std::io::ErrorKind::NotFound {
762                not_found(spec)
763            } else {
764                PackageReadFailure::new(
765                    spec.clone(),
766                    PackageReadFailureReason::NetworkFailed {
767                        detail: Some(source.to_string()),
768                    },
769                )
770            };
771            FilesystemPackageAuthorityReadError::Download { failure, source }
772        })?;
773        let known_size = known_size
774            .map(u64::try_from)
775            .transpose()
776            .map_err(|source| FilesystemPackageAuthorityReadError::DownloadSize {
777                failure: other_failure(spec, "download size is not representable"),
778                source,
779            })?;
780        let tree = read_registry_tree(spec, reader, known_size, self.expansion_limits)?;
781
782        let root = if let Some(cache) = &self.cache {
783            cache
784                .store(spec, |directory| write_tree(directory, &tree))
785                .map_err(|source| FilesystemPackageAuthorityReadError::Cache {
786                    failure: other_failure(spec, source.to_string()),
787                    source: Box::new(source),
788                })?;
789            Some(package_root(cache.path(), spec))
790        } else {
791            None
792        };
793        Ok(ReadPackage { tree, root })
794    }
795}
796
797/// One successful read from the concrete filesystem Package Authority.
798#[derive(Debug)]
799pub struct ReadPackage {
800    tree: PackageTree,
801    root: Option<PathBuf>,
802}
803
804impl ReadPackage {
805    /// The validated Package Tree produced by this read.
806    pub fn tree(&self) -> &PackageTree {
807        &self.tree
808    }
809
810    /// The package's filesystem root, when one backs dependency reporting.
811    pub fn root(&self) -> Option<&Path> {
812        self.root.as_deref()
813    }
814
815    /// Separates the validated tree from optional filesystem source evidence.
816    pub fn into_parts(self) -> (PackageTree, Option<PathBuf>) {
817        (self.tree, self.root)
818    }
819}
820
821fn not_found(spec: &PackageSpec) -> PackageReadFailure {
822    PackageReadFailure::new(spec.clone(), PackageReadFailureReason::NotFound)
823}
824
825fn other_failure(spec: &PackageSpec, detail: impl Into<String>) -> PackageReadFailure {
826    PackageReadFailure::new(
827        spec.clone(),
828        PackageReadFailureReason::Other {
829            detail: Some(detail.into()),
830        },
831    )
832}
833
834#[cfg(feature = "egress")]
835fn read_registry_tree(
836    spec: &PackageSpec,
837    reader: impl Read,
838    known_size: Option<u64>,
839    limits: PackageExpansionLimits,
840) -> Result<PackageTree, FilesystemPackageAuthorityReadError> {
841    let archive = read_package_archive(reader, known_size, limits).map_err(|source| {
842        let failure = match &source {
843            PackageArchiveReadError::Read(error) => PackageReadFailure::new(
844                spec.clone(),
845                PackageReadFailureReason::NetworkFailed {
846                    detail: Some(error.to_string()),
847                },
848            ),
849            PackageArchiveReadError::Limit(error) => other_failure(spec, error.to_string()),
850        };
851        FilesystemPackageAuthorityReadError::ArchiveRead {
852            failure,
853            source: Box::new(source),
854        }
855    })?;
856    expand_package_archive(spec.clone(), &archive, limits).map_err(|source| {
857        let failure = match &source {
858            PackageReadError::UnservedNamespace { .. } => not_found(spec),
859            PackageReadError::ExpansionLimit { .. } => other_failure(spec, source.to_string()),
860            PackageReadError::MalformedArchive { .. }
861            | PackageReadError::InvalidPackageTree { .. } => PackageReadFailure::new(
862                spec.clone(),
863                PackageReadFailureReason::MalformedArchive {
864                    detail: Some(source.to_string()),
865                },
866            ),
867        };
868        FilesystemPackageAuthorityReadError::ArchiveExpansion {
869            failure,
870            source: Box::new(source),
871        }
872    })
873}
874
875#[cfg(feature = "egress")]
876fn package_root(base: &Path, spec: &PackageSpec) -> PathBuf {
877    base.join(crate::read_layout::package_tree_key(spec))
878}
879
880#[cfg(feature = "egress")]
881fn write_tree(directory: &Path, tree: &PackageTree) -> typst::diag::PackageResult<()> {
882    for (path, data) in tree.files() {
883        let destination = directory.join(path);
884        if let Some(parent) = destination.parent() {
885            std::fs::create_dir_all(parent).map_err(package_cache_error)?;
886        }
887        std::fs::write(destination, data).map_err(package_cache_error)?;
888    }
889    Ok(())
890}
891
892#[cfg(feature = "egress")]
893fn package_cache_error(error: std::io::Error) -> PackageError {
894    PackageError::Other(Some(
895        format!("failed to cache downloaded package: {error}").into(),
896    ))
897}
898
899/// Exact Package Trees retained for representative-compile diagnostics.
900pub(crate) struct ReadPackages {
901    trees: Mutex<Vec<(PackageSpec, PackageTree)>>,
902}
903
904impl ReadPackages {
905    pub(crate) fn new() -> Self {
906        Self {
907            trees: Mutex::new(Vec::new()),
908        }
909    }
910
911    pub(crate) fn record(&self, spec: PackageSpec, tree: PackageTree) {
912        self.trees
913            .lock()
914            .expect("read package lock poisoned")
915            .push((spec, tree));
916    }
917
918    /// The exact bytes read for one package file, which is all creation
919    /// diagnostics and timing spans may still resolve a package source from.
920    pub(crate) fn file(&self, spec: &PackageSpec, path: &str) -> Option<Bytes> {
921        self.trees
922            .lock()
923            .expect("read package lock poisoned")
924            .iter()
925            .find(|(candidate, _)| candidate == spec)?
926            .1
927            .shared_file(path)
928            .map(|data| data.to_typst())
929    }
930}
931
932#[cfg(feature = "egress")]
933struct RustlsDownloader {
934    user_agent: &'static str,
935    certificate: Option<PathBuf>,
936    tls: OnceLock<Result<Option<Arc<ureq::rustls::ClientConfig>>, String>>,
937}
938
939#[cfg(feature = "egress")]
940impl RustlsDownloader {
941    fn new(user_agent: &'static str, certificate: Option<PathBuf>) -> Self {
942        Self {
943            user_agent,
944            certificate,
945            tls: OnceLock::new(),
946        }
947    }
948
949    /// Reads one PEM file of extra trust anchors on top of the bundled roots.
950    ///
951    /// PEM parsing comes from rustls-pki-types, which absorbed it from the
952    /// now-unmaintained rustls-pemfile (RUSTSEC-2025-0134).
953    fn root_store(path: &Path) -> Result<ureq::rustls::RootCertStore, String> {
954        use ureq::rustls::pki_types::CertificateDer;
955        use ureq::rustls::pki_types::pem::PemObject;
956
957        let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
958        let reader = BufReader::new(file);
959        let mut roots = ureq::rustls::RootCertStore {
960            roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
961        };
962        for certificate in CertificateDer::pem_reader_iter(reader) {
963            let certificate = certificate.map_err(|error| error.to_string())?;
964            roots.add(certificate).map_err(|error| error.to_string())?;
965        }
966        Ok(roots)
967    }
968
969    fn tls_config(&self) -> std::io::Result<Option<Arc<ureq::rustls::ClientConfig>>> {
970        match self.tls.get_or_init(|| {
971            let Some(path) = &self.certificate else {
972                return Ok(None);
973            };
974            let roots = Self::root_store(path)?;
975            let tls = ureq::rustls::ClientConfig::builder()
976                .with_root_certificates(roots)
977                .with_no_client_auth();
978            Ok(Some(Arc::new(tls)))
979        }) {
980            Ok(tls) => Ok(tls.clone()),
981            Err(error) => Err(std::io::Error::other(error.clone())),
982        }
983    }
984}
985
986#[cfg(feature = "egress")]
987impl typst_kit::downloader::Downloader for RustlsDownloader {
988    fn stream(
989        &self,
990        _key: &dyn std::any::Any,
991        url: &str,
992    ) -> std::io::Result<(Option<usize>, Box<dyn Read>)> {
993        #[cfg(all(feature = "_test-package-download-probe", debug_assertions))]
994        if let Some(output) = std::env::var_os(PACKAGE_DOWNLOAD_PROBE_ENV) {
995            let certificate = self
996                .certificate
997                .as_deref()
998                .map(|path| path.to_string_lossy())
999                .unwrap_or_default();
1000            std::fs::write(output, certificate.as_bytes())?;
1001            return Err(std::io::Error::new(
1002                std::io::ErrorKind::PermissionDenied,
1003                "package download stopped by test probe",
1004            ));
1005        }
1006
1007        let mut builder = ureq::AgentBuilder::new().user_agent(self.user_agent);
1008        if let Some(proxy) = env_proxy::for_url_str(url)
1009            .to_url()
1010            .and_then(|url| ureq::Proxy::new(url).ok())
1011        {
1012            builder = builder.proxy(proxy);
1013        }
1014        if let Some(tls) = self.tls_config()? {
1015            builder = builder.tls_config(tls);
1016        }
1017        let response = builder
1018            .build()
1019            .get(url)
1020            .call()
1021            .map_err(|error| match error {
1022                ureq::Error::Status(404, _) => {
1023                    std::io::Error::new(std::io::ErrorKind::NotFound, error)
1024                }
1025                error => std::io::Error::other(error),
1026            })?;
1027        let content_length = response
1028            .header("Content-Length")
1029            .and_then(|value| value.parse().ok());
1030        Ok((content_length, response.into_reader()))
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    /// The custom-certificate path is the crate's only PEM parsing. It has no
1039    /// process-level coverage, so this pins the behavior directly.
1040    #[cfg(feature = "egress")]
1041    const TEST_CERTIFICATE_PEM: &str = "\
1042-----BEGIN CERTIFICATE-----\n\
1043MIIDHTCCAgWgAwIBAgIUd7rNizjvFLekUK8kk8YGERd1Ru8wDQYJKoZIhvcNAQEL\n\
1044BQAwHTEbMBkGA1UEAwwSdHlwc3QtcGFjayB0ZXN0IENBMCAXDTI2MDgyNTEyNTcz\n\
1045NloYDzIxMjYwODAxMTI1NzM2WjAdMRswGQYDVQQDDBJ0eXBzdC1wYWNrIHRlc3Qg\n\
1046Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaimCA3A7auE+n08On\n\
10474RKiJPJzM8Aqv9zYvjXRJsMuo1aXBme9MLYEAu+T9DCxPWaVJYEKqZEkNYJVOF04\n\
10485ONBZYsk7uQ8a72DZtBV123RXWPa/cD9ieXVgN4oRC2FdfyELyTcUHtRo1zFlSSx\n\
1049dz2TY0Zzk5ON62d1O89zdeUPJ/mah4jxuPJeFPTeJZFdYOjaOSUr06MVWgYQ1hC0\n\
1050nNYG3rFqVucb+83stE1nZFBAegwrdE6poQUFT3rE+ApxVzHDWi1gaD3VVBszmMjZ\n\
1051FwufcnW2kCXVvmM5YebnvLyqIHvNddWkMygWSPwAJDFXWRdi6MzYkoC1qTpbkj8J\n\
1052PI+7AgMBAAGjUzBRMB0GA1UdDgQWBBRlvjvjxE0mp20Do4trUIL0RMDa8jAfBgNV\n\
1053HSMEGDAWgBRlvjvjxE0mp20Do4trUIL0RMDa8jAPBgNVHRMBAf8EBTADAQH/MA0G\n\
1054CSqGSIb3DQEBCwUAA4IBAQAigZCXG4KVwXHFE7G0wLxWuWizmLgj2fEU7W0Wy1rp\n\
1055qXEOlKCNG0MX/JTlqydyv+ZSKPMyl9eX+SPFlhlm/Az8kPHYWd7CqdO4Kz0fSEhx\n\
1056IrChOWM5OfEUn+JXRSDJnBFcYVgucKdeC9yiP3WqQc0fJ5j14+tWUsMU1lr/392L\n\
1057xZni29e/+sP8VvO2eGO+CyYsAlgTJ0Ka1QwSJYS3jLMfYhsNpMpnmzL7bAy+Uyuz\n\
1058/Xt4qjy1STvhUAruckTFLjJ/6i0GK2mm0XjI4/xXoyh2c40yj16l87Ncur2bjQ3o\n\
1059D/11HiutermQt0RJByBT1FPDdLyRtRPwY1PoAi2/OlAb\n\
1060-----END CERTIFICATE-----\n\
1061";
1062
1063    #[cfg(feature = "egress")]
1064    #[test]
1065    fn custom_certificate_pem_is_added_to_the_root_store() {
1066        let directory = tempfile::tempdir().unwrap();
1067        let path = directory.path().join("ca.pem");
1068        std::fs::write(&path, TEST_CERTIFICATE_PEM).unwrap();
1069
1070        // The bundled roots plus exactly the one certificate supplied here.
1071        let roots = RustlsDownloader::root_store(&path).expect("a valid PEM certificate is read");
1072        assert_eq!(roots.roots.len(), webpki_roots::TLS_SERVER_ROOTS.len() + 1);
1073
1074        let downloader = RustlsDownloader::new("typst-pack-test", Some(path));
1075        assert!(
1076            downloader
1077                .tls_config()
1078                .expect("a valid PEM certificate is accepted")
1079                .is_some()
1080        );
1081    }
1082
1083    /// A file with no PEM section adds no trust anchor and is not an error, so
1084    /// a misdirected `--cert` silently falls back to the bundled roots. This
1085    /// matches the rustls-pemfile behavior it replaced.
1086    #[cfg(feature = "egress")]
1087    #[test]
1088    fn certificate_file_without_a_pem_section_keeps_the_default_roots() {
1089        let directory = tempfile::tempdir().unwrap();
1090        let path = directory.path().join("ca.pem");
1091        std::fs::write(&path, "not a certificate").unwrap();
1092
1093        let roots =
1094            RustlsDownloader::root_store(&path).expect("a section-free file is not an error");
1095        assert_eq!(roots.roots.len(), webpki_roots::TLS_SERVER_ROOTS.len());
1096    }
1097
1098    #[cfg(feature = "egress")]
1099    #[test]
1100    fn corrupt_certificate_pem_is_rejected() {
1101        let directory = tempfile::tempdir().unwrap();
1102        let path = directory.path().join("ca.pem");
1103        std::fs::write(
1104            &path,
1105            "-----BEGIN CERTIFICATE-----\nnot base64!!\n-----END CERTIFICATE-----\n",
1106        )
1107        .unwrap();
1108
1109        assert!(RustlsDownloader::root_store(&path).is_err());
1110
1111        let downloader = RustlsDownloader::new("typst-pack-test", Some(path));
1112        assert!(downloader.tls_config().is_err());
1113    }
1114
1115    #[cfg(feature = "egress")]
1116    #[test]
1117    fn absent_certificate_leaves_the_default_roots_in_place() {
1118        let downloader = RustlsDownloader::new("typst-pack-test", None);
1119        assert!(downloader.tls_config().unwrap().is_none());
1120    }
1121
1122    #[cfg(feature = "egress")]
1123    fn package_archive(files: &[(&str, &[u8])]) -> Vec<u8> {
1124        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
1125            Vec::new(),
1126            flate2::Compression::default(),
1127        ));
1128        for (path, data) in files {
1129            let mut header = tar::Header::new_gnu();
1130            header.set_size(data.len() as u64);
1131            header.set_mode(0o644);
1132            builder.append_data(&mut header, path, *data).unwrap();
1133        }
1134        builder.into_inner().unwrap().finish().unwrap()
1135    }
1136
1137    #[test]
1138    fn package_tree_accounting_overflow_is_typed() {
1139        assert_eq!(
1140            checked_add(u64::MAX, 1, FilesystemPackageResource::PackageTreeBytes),
1141            Err(FilesystemPackageLimitError::AccountingOverflow {
1142                resource: FilesystemPackageResource::PackageTreeBytes,
1143            })
1144        );
1145    }
1146
1147    #[test]
1148    fn incremental_package_file_read_stops_at_the_plus_one_byte() {
1149        let error =
1150            read_bounded_package_file(&b"12345-extra"[..], Path::new("package.typ"), 4, 0, 100)
1151                .unwrap_err();
1152        assert!(matches!(
1153            error,
1154            FilesystemPackageReadError::Limit {
1155                source,
1156                ..
1157            } if source == FilesystemPackageLimitError::exceeded(
1158                FilesystemPackageResource::SelectedFileBytes,
1159                4,
1160            )
1161        ));
1162    }
1163
1164    #[test]
1165    fn incremental_package_tree_read_reports_accounting_overflow() {
1166        let error =
1167            read_bounded_package_file(&b"x"[..], Path::new("package.typ"), 1, u64::MAX, u64::MAX)
1168                .unwrap_err();
1169        assert!(matches!(
1170            error,
1171            FilesystemPackageReadError::Limit {
1172                source: FilesystemPackageLimitError::AccountingOverflow {
1173                    resource: FilesystemPackageResource::PackageTreeBytes,
1174                },
1175                ..
1176            }
1177        ));
1178    }
1179
1180    #[cfg(feature = "egress")]
1181    #[test]
1182    fn registry_response_bytes_are_bounded_then_expanded_without_a_reread() {
1183        let spec = "@preview/example:1.0.0".parse().unwrap();
1184        let archive = package_archive(&[("lib.typ", b"exact registry bytes")]);
1185
1186        let tree = read_registry_tree(
1187            &spec,
1188            std::io::Cursor::new(&archive),
1189            Some(archive.len() as u64),
1190            PackageExpansionLimits::reference_v1(),
1191        )
1192        .unwrap();
1193
1194        assert_eq!(tree.file("lib.typ"), Some(&b"exact registry bytes"[..]));
1195    }
1196
1197    #[cfg(feature = "egress")]
1198    #[test]
1199    fn registry_expansion_limits_retain_the_typed_cause_without_claiming_malformed_bytes() {
1200        let spec = "@preview/example:1.0.0".parse().unwrap();
1201        let archive = package_archive(&[("lib.typ", b"12345")]);
1202        let limits = PackageExpansionLimits::new(1024 * 1024, 10, 100, 4, 100);
1203
1204        let error = read_registry_tree(
1205            &spec,
1206            std::io::Cursor::new(&archive),
1207            Some(archive.len() as u64),
1208            limits,
1209        )
1210        .unwrap_err();
1211
1212        let FilesystemPackageAuthorityReadError::ArchiveExpansion { failure, source } = error
1213        else {
1214            panic!("expected a typed archive expansion cause");
1215        };
1216        assert!(matches!(
1217            failure.reason(),
1218            PackageReadFailureReason::Other { .. }
1219        ));
1220        assert!(matches!(
1221            source.as_ref(),
1222            PackageReadError::ExpansionLimit { .. }
1223        ));
1224    }
1225}