Skip to main content

typst_pack/
fs_project.rs

1//! Project reading for the reference filesystem source.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use ignore::gitignore::{Gitignore, GitignoreBuilder};
7
8use crate::error_display::format_error_list;
9use crate::fs_traversal::{self, TraversalPolicy, UnsupportedEntry};
10use crate::limits::{LimitError, Limits, ResourceKind};
11use crate::pack::names_pack_path;
12use crate::project_snapshot::{ProjectSnapshot, ProjectSnapshotAssembly, ProjectSnapshotError};
13
14/// The root-relative path of the filesystem Project Ignore Policy file.
15pub const IGNORE_FILE: &str = ".typkignore";
16
17/// A resource bounded during filesystem project reading.
18pub type FilesystemProjectResource = ResourceKind<0>;
19
20#[allow(non_upper_case_globals)]
21impl ResourceKind<0> {
22    pub const VisitedEntries: Self = Self::new(0);
23    pub const SelectedFiles: Self = Self::new(1);
24    pub const RootPolicyBytes: Self = Self::new(2);
25    pub const SelectedFileBytes: Self = Self::new(3);
26    pub const TotalSelectedBytes: Self = Self::new(4);
27}
28
29/// A filesystem project exceeded a mandatory reading ceiling.
30pub type FilesystemProjectLimitError = LimitError<FilesystemProjectResource>;
31
32/// Mandatory finite resource ceilings for filesystem project reading.
33pub type FilesystemProjectLimits = Limits<FilesystemProjectResource>;
34
35impl Limits<FilesystemProjectResource> {
36    /// Constructs validated mandatory finite reading ceilings.
37    #[track_caller]
38    pub fn new(
39        visited_entries: u64,
40        selected_files: u64,
41        root_policy_bytes: u64,
42        selected_file_bytes: u64,
43        total_selected_bytes: u64,
44    ) -> Self {
45        Self::from_ceilings([
46            visited_entries,
47            selected_files,
48            root_policy_bytes,
49            selected_file_bytes,
50            total_selected_bytes,
51            0,
52            0,
53        ])
54        .assert_probe_resources([
55            FilesystemProjectResource::VisitedEntries,
56            FilesystemProjectResource::SelectedFiles,
57            FilesystemProjectResource::RootPolicyBytes,
58            FilesystemProjectResource::SelectedFileBytes,
59            FilesystemProjectResource::TotalSelectedBytes,
60        ])
61    }
62
63    /// The first-party limits for filesystem projects.
64    pub const fn reference_v1() -> Self {
65        Self::from_ceilings([
66            1_000_000,
67            100_000,
68            1024 * 1024,
69            256 * 1024 * 1024,
70            2 * 1024 * 1024 * 1024,
71            0,
72            0,
73        ])
74    }
75
76    pub const fn visited_entries(&self) -> u64 {
77        self.ceilings[0]
78    }
79
80    pub const fn selected_files(&self) -> u64 {
81        self.ceilings[1]
82    }
83
84    pub const fn root_policy_bytes(&self) -> u64 {
85        self.ceilings[2]
86    }
87
88    pub const fn selected_file_bytes(&self) -> u64 {
89        self.ceilings[3]
90    }
91
92    pub const fn total_selected_bytes(&self) -> u64 {
93        self.ceilings[4]
94    }
95}
96
97/// The kind of an eligible filesystem entry that cannot become a project file.
98#[derive(Debug, Clone, Copy, Eq, PartialEq)]
99#[non_exhaustive]
100pub enum FilesystemProjectEntryKind {
101    Socket,
102    Fifo,
103    BlockDevice,
104    CharacterDevice,
105    Unknown,
106}
107
108/// The filesystem operation that produced an I/O error while reading.
109#[derive(Debug, Clone, Copy, Eq, PartialEq)]
110#[non_exhaustive]
111pub enum FilesystemProjectOperation {
112    InspectRootPolicy,
113    ReadRootPolicy,
114    SurveyEntry,
115    InspectSelectedFile,
116    ReadSelectedFile,
117}
118
119impl std::fmt::Display for FilesystemProjectOperation {
120    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        formatter.write_str(match self {
122            Self::InspectRootPolicy => "inspect root Project Ignore Policy",
123            Self::ReadRootPolicy => "read root Project Ignore Policy",
124            Self::SurveyEntry => "survey project entry",
125            Self::InspectSelectedFile => "inspect selected project file",
126            Self::ReadSelectedFile => "read selected project file",
127        })
128    }
129}
130
131/// One independently detectable filesystem project survey issue.
132#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
133#[non_exhaustive]
134pub enum FilesystemProjectIssue {
135    #[error("unsupported filesystem entry `{}`: aliases cannot become project files", path.display())]
136    Alias { path: PathBuf },
137    #[error("unsupported filesystem entry `{}` in the project", path.display())]
138    UnsupportedEntry {
139        path: PathBuf,
140        kind: FilesystemProjectEntryKind,
141    },
142    #[error("project path `{}` is not valid UTF-8", path.display())]
143    UnrepresentablePath { path: PathBuf },
144}
145
146impl FilesystemProjectIssue {
147    fn path(&self) -> &Path {
148        match self {
149            Self::Alias { path }
150            | Self::UnsupportedEntry { path, .. }
151            | Self::UnrepresentablePath { path } => path,
152        }
153    }
154
155    fn rank(&self) -> u8 {
156        match self {
157            Self::Alias { .. } => 0,
158            Self::UnsupportedEntry { .. } => 1,
159            Self::UnrepresentablePath { .. } => 2,
160        }
161    }
162}
163
164/// All safely detectable issues found by one filesystem structural survey.
165#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
166#[error(
167    "filesystem project survey found {} issue(s){}",
168    .issues.len(),
169    format_error_list(.issues.as_slice())
170)]
171pub struct FilesystemProjectSurveyError {
172    issues: Vec<FilesystemProjectIssue>,
173}
174
175impl FilesystemProjectSurveyError {
176    pub fn issues(&self) -> &[FilesystemProjectIssue] {
177        &self.issues
178    }
179}
180
181/// A failure while parsing the root filesystem Project Ignore Policy.
182#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
183#[non_exhaustive]
184pub enum FilesystemProjectPolicyError {
185    #[error("the policy file is not valid UTF-8")]
186    NotUtf8,
187    #[error("line {line}: {message}")]
188    InvalidRule { line: usize, message: String },
189    #[error("{0}")]
190    Invalid(String),
191}
192
193/// A failure while reading a Project Snapshot from the filesystem.
194#[derive(Debug, thiserror::Error)]
195#[non_exhaustive]
196pub enum FilesystemProjectReadError {
197    #[error("failed to {operation} `{}`: {source}", path.display())]
198    Io {
199        operation: FilesystemProjectOperation,
200        path: PathBuf,
201        #[source]
202        source: std::io::Error,
203    },
204    #[error("invalid Project Ignore Policy at `{}`: {source}", path.display())]
205    InvalidPolicy {
206        path: PathBuf,
207        source: FilesystemProjectPolicyError,
208    },
209    #[error(transparent)]
210    Survey(FilesystemProjectSurveyError),
211    #[error("filesystem project resource limit at {path:?}: {source}")]
212    Limit {
213        path: PathBuf,
214        source: FilesystemProjectLimitError,
215    },
216    #[error("selected filesystem entries do not form a Project Snapshot: {0}")]
217    Snapshot(#[source] ProjectSnapshotError),
218}
219
220impl FilesystemProjectReadError {
221    fn io(
222        operation: FilesystemProjectOperation,
223        path: impl Into<PathBuf>,
224        source: std::io::Error,
225    ) -> Self {
226        Self::Io {
227            operation,
228            path: path.into(),
229            source,
230        }
231    }
232
233    fn limit(path: impl Into<PathBuf>, source: FilesystemProjectLimitError) -> Self {
234        Self::Limit {
235            path: path.into(),
236            source,
237        }
238    }
239}
240
241/// Reads one Project Snapshot from the reference filesystem source.
242///
243/// The root policy is read and parsed once. Reading then surveys and
244/// filters the complete eligible structure before reading ordinary selected
245/// files, and submits only the selected exact bytes to Project Snapshot
246/// Assembly.
247pub fn read_filesystem_project(
248    root: impl AsRef<Path>,
249    entrypoint: impl Into<String>,
250    limits: FilesystemProjectLimits,
251) -> Result<ProjectSnapshot, FilesystemProjectReadError> {
252    let root = root.as_ref();
253    let policy_path = root.join(IGNORE_FILE);
254    let policy_file = read_policy(&policy_path, limits)?;
255    let policy = &policy_file.policy;
256
257    let mut visited_entries = 0u64;
258    let mut selected_files = u64::from(policy_file.bytes.is_some());
259    check_limit(
260        FilesystemProjectResource::SelectedFiles,
261        limits.selected_files(),
262        selected_files,
263    )
264    .map_err(|source| FilesystemProjectReadError::limit(&policy_path, source))?;
265    let mut declared_total = policy_file
266        .bytes
267        .as_ref()
268        .map_or(0, |bytes| bytes.len() as u64);
269    let mut selected = Vec::new();
270    let mut issues = Vec::new();
271    let mut deferred_limit = None;
272    let mut walk = walkdir::WalkDir::new(root).follow_links(false).into_iter();
273
274    while let Some(entry) = walk.next() {
275        let entry = entry.map_err(|error| {
276            let path = error.path().unwrap_or(root).to_owned();
277            let source = error
278                .into_io_error()
279                .unwrap_or_else(|| std::io::Error::other("filesystem traversal failed"));
280            FilesystemProjectReadError::io(FilesystemProjectOperation::SurveyEntry, path, source)
281        })?;
282        if entry.depth() == 0 {
283            continue;
284        }
285
286        visited_entries = checked_add(
287            visited_entries,
288            1,
289            FilesystemProjectResource::VisitedEntries,
290        )
291        .map_err(|source| FilesystemProjectReadError::limit(entry.path(), source))?;
292        check_limit(
293            FilesystemProjectResource::VisitedEntries,
294            limits.visited_entries(),
295            visited_entries,
296        )
297        .map_err(|source| FilesystemProjectReadError::limit(entry.path(), source))?;
298
299        let relative = entry
300            .path()
301            .strip_prefix(root)
302            .expect("walk remains beneath root");
303        let Some(path) = slash_path(relative) else {
304            issues.push(FilesystemProjectIssue::UnrepresentablePath {
305                path: entry.path().to_owned(),
306            });
307            if entry.file_type().is_dir() {
308                walk.skip_current_dir();
309            }
310            continue;
311        };
312
313        if path == IGNORE_FILE {
314            if entry.file_type().is_dir() {
315                walk.skip_current_dir();
316            }
317            continue;
318        }
319
320        let file_type = entry.file_type();
321        if file_type.is_dir() {
322            if policy.excludes_directory(&path) {
323                walk.skip_current_dir();
324            }
325            continue;
326        }
327        if policy.excludes_file(&path) {
328            continue;
329        }
330        if file_type.is_symlink() {
331            issues.push(FilesystemProjectIssue::Alias {
332                path: entry.path().to_owned(),
333            });
334            continue;
335        }
336        if !file_type.is_file() {
337            issues.push(FilesystemProjectIssue::UnsupportedEntry {
338                path: entry.path().to_owned(),
339                kind: UnsupportedEntry::of(&file_type).into(),
340            });
341            continue;
342        }
343
344        selected_files =
345            checked_add(selected_files, 1, FilesystemProjectResource::SelectedFiles)
346                .map_err(|source| FilesystemProjectReadError::limit(entry.path(), source))?;
347        if let Err(source) = check_limit(
348            FilesystemProjectResource::SelectedFiles,
349            limits.selected_files(),
350            selected_files,
351        ) {
352            deferred_limit
353                .get_or_insert_with(|| FilesystemProjectReadError::limit(entry.path(), source));
354            continue;
355        }
356
357        let metadata = entry.metadata().map_err(|error| {
358            let path = error.path().unwrap_or(entry.path()).to_owned();
359            let source = error.into_io_error().unwrap_or_else(|| {
360                std::io::Error::other("failed to inspect selected project file")
361            });
362            FilesystemProjectReadError::io(
363                FilesystemProjectOperation::InspectSelectedFile,
364                path,
365                source,
366            )
367        })?;
368        let declared = metadata.len();
369        if let Err(source) = check_limit(
370            FilesystemProjectResource::SelectedFileBytes,
371            limits.selected_file_bytes(),
372            declared,
373        ) {
374            deferred_limit
375                .get_or_insert_with(|| FilesystemProjectReadError::limit(entry.path(), source));
376            continue;
377        }
378        declared_total = checked_add(
379            declared_total,
380            declared,
381            FilesystemProjectResource::TotalSelectedBytes,
382        )
383        .map_err(|source| FilesystemProjectReadError::limit(entry.path(), source))?;
384        if let Err(source) = check_limit(
385            FilesystemProjectResource::TotalSelectedBytes,
386            limits.total_selected_bytes(),
387            declared_total,
388        ) {
389            deferred_limit
390                .get_or_insert_with(|| FilesystemProjectReadError::limit(entry.path(), source));
391            continue;
392        }
393        selected.push((path, entry.path().to_owned()));
394    }
395
396    if !issues.is_empty() {
397        issues.sort_by(|left, right| {
398            left.path()
399                .cmp(right.path())
400                .then_with(|| left.rank().cmp(&right.rank()))
401        });
402        return Err(FilesystemProjectReadError::Survey(
403            FilesystemProjectSurveyError { issues },
404        ));
405    }
406    if let Some(error) = deferred_limit {
407        return Err(error);
408    }
409
410    selected.sort_by(|(left, _), (right, _)| left.cmp(right));
411    let mut entries = Vec::with_capacity(selected.len() + usize::from(policy_file.bytes.is_some()));
412    let mut actual_total = 0u64;
413    if let Some(bytes) = policy_file.bytes {
414        actual_total = bytes.len() as u64;
415        entries.push((IGNORE_FILE.to_owned(), bytes));
416    }
417    for (path, source) in selected {
418        let remaining = limits.total_selected_bytes() - actual_total;
419        let bytes = read_bounded(
420            root,
421            &source,
422            &[
423                (
424                    FilesystemProjectResource::SelectedFileBytes,
425                    limits.selected_file_bytes(),
426                    limits.selected_file_bytes(),
427                    0,
428                ),
429                (
430                    FilesystemProjectResource::TotalSelectedBytes,
431                    remaining,
432                    limits.total_selected_bytes(),
433                    actual_total,
434                ),
435            ],
436            FilesystemProjectOperation::ReadSelectedFile,
437        )?;
438        actual_total = checked_add(
439            actual_total,
440            bytes.len() as u64,
441            FilesystemProjectResource::TotalSelectedBytes,
442        )
443        .map_err(|source_error| FilesystemProjectReadError::limit(&source, source_error))?;
444        entries.push((path, bytes));
445    }
446
447    ProjectSnapshotAssembly::new(entrypoint)
448        .assemble(entries)
449        .map_err(FilesystemProjectReadError::Snapshot)
450}
451
452struct ReadPolicy {
453    policy: ProjectIgnorePolicy,
454    bytes: Option<Vec<u8>>,
455}
456
457fn read_policy(
458    path: &Path,
459    limits: FilesystemProjectLimits,
460) -> Result<ReadPolicy, FilesystemProjectReadError> {
461    let metadata = match std::fs::symlink_metadata(path) {
462        Ok(metadata) => metadata,
463        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
464            return Ok(ReadPolicy {
465                policy: ProjectIgnorePolicy::built_in(),
466                bytes: None,
467            });
468        }
469        Err(error) => {
470            return Err(FilesystemProjectReadError::io(
471                FilesystemProjectOperation::InspectRootPolicy,
472                path,
473                error,
474            ));
475        }
476    };
477    let file_type = metadata.file_type();
478    if file_type.is_symlink() {
479        return Err(FilesystemProjectReadError::Survey(
480            FilesystemProjectSurveyError {
481                issues: vec![FilesystemProjectIssue::Alias {
482                    path: path.to_owned(),
483                }],
484            },
485        ));
486    }
487    if !file_type.is_file() {
488        return Err(FilesystemProjectReadError::Survey(
489            FilesystemProjectSurveyError {
490                issues: vec![FilesystemProjectIssue::UnsupportedEntry {
491                    path: path.to_owned(),
492                    kind: UnsupportedEntry::of(&file_type).into(),
493                }],
494            },
495        ));
496    }
497
498    check_limit(
499        FilesystemProjectResource::VisitedEntries,
500        limits.visited_entries(),
501        1,
502    )
503    .and_then(|_| {
504        check_limit(
505            FilesystemProjectResource::SelectedFiles,
506            limits.selected_files(),
507            1,
508        )
509    })
510    .map_err(|source| FilesystemProjectReadError::limit(path, source))?;
511    check_limit(
512        FilesystemProjectResource::RootPolicyBytes,
513        limits.root_policy_bytes(),
514        metadata.len(),
515    )
516    .and_then(|_| {
517        check_limit(
518            FilesystemProjectResource::SelectedFileBytes,
519            limits.selected_file_bytes(),
520            metadata.len(),
521        )
522    })
523    .and_then(|_| {
524        check_limit(
525            FilesystemProjectResource::TotalSelectedBytes,
526            limits.total_selected_bytes(),
527            metadata.len(),
528        )
529    })
530    .map_err(|source| FilesystemProjectReadError::limit(path, source))?;
531    let bytes = read_bounded(
532        path.parent().expect("the policy path is beneath a root"),
533        path,
534        &[
535            (
536                FilesystemProjectResource::RootPolicyBytes,
537                limits.root_policy_bytes(),
538                limits.root_policy_bytes(),
539                0,
540            ),
541            (
542                FilesystemProjectResource::SelectedFileBytes,
543                limits.selected_file_bytes(),
544                limits.selected_file_bytes(),
545                0,
546            ),
547            (
548                FilesystemProjectResource::TotalSelectedBytes,
549                limits.total_selected_bytes(),
550                limits.total_selected_bytes(),
551                0,
552            ),
553        ],
554        FilesystemProjectOperation::ReadRootPolicy,
555    )?;
556    let policy = ProjectIgnorePolicy::from_bytes(&bytes).map_err(|source| {
557        FilesystemProjectReadError::InvalidPolicy {
558            path: path.to_owned(),
559            source,
560        }
561    })?;
562    Ok(ReadPolicy {
563        policy,
564        bytes: Some(bytes),
565    })
566}
567
568fn read_bounded(
569    root: &Path,
570    path: &Path,
571    ceilings: &[(FilesystemProjectResource, u64, u64, u64)],
572    operation: FilesystemProjectOperation,
573) -> Result<Vec<u8>, FilesystemProjectReadError> {
574    let probe_ceiling = ceilings
575        .iter()
576        .map(|(_, allowance, _, _)| *allowance)
577        .min()
578        .expect("a bounded read has at least one ceiling");
579    let mut file =
580        fs_traversal::open_without_following(&ProjectTraversal { operation }, root, path)?;
581    let mut bytes = Vec::new();
582    file.by_ref()
583        .take(probe_ceiling + 1)
584        .read_to_end(&mut bytes)
585        .map_err(|error| FilesystemProjectReadError::io(operation, path, error))?;
586    let observed = u64::try_from(bytes.len()).map_err(|_| {
587        FilesystemProjectReadError::limit(
588            path,
589            FilesystemProjectLimitError::AccountingOverflow {
590                resource: ceilings[0].0,
591            },
592        )
593    })?;
594    for (resource, _, ceiling, base) in ceilings {
595        let cumulative = checked_add(*base, observed, *resource)
596            .map_err(|source| FilesystemProjectReadError::limit(path, source))?;
597        check_limit(*resource, *ceiling, cumulative)
598            .map_err(|source| FilesystemProjectReadError::limit(path, source))?;
599    }
600    Ok(bytes)
601}
602
603/// The project reader's vocabulary for the shared symlink-refusing open.
604struct ProjectTraversal {
605    operation: FilesystemProjectOperation,
606}
607
608impl TraversalPolicy for ProjectTraversal {
609    type Error = FilesystemProjectReadError;
610
611    const ROOT_INVARIANT: &'static str = "a selected path remains beneath its project root";
612
613    fn io(&self, path: &Path, source: std::io::Error) -> Self::Error {
614        FilesystemProjectReadError::io(self.operation, path, source)
615    }
616
617    fn alias(&self, path: &Path) -> Self::Error {
618        alias_error(path)
619    }
620
621    fn unsupported_entry(&self, path: &Path, entry: UnsupportedEntry) -> Self::Error {
622        FilesystemProjectReadError::Survey(FilesystemProjectSurveyError {
623            issues: vec![FilesystemProjectIssue::UnsupportedEntry {
624                path: path.to_owned(),
625                kind: entry.into(),
626            }],
627        })
628    }
629}
630
631impl From<UnsupportedEntry> for FilesystemProjectEntryKind {
632    fn from(entry: UnsupportedEntry) -> Self {
633        match entry {
634            UnsupportedEntry::Socket => Self::Socket,
635            UnsupportedEntry::Fifo => Self::Fifo,
636            UnsupportedEntry::BlockDevice => Self::BlockDevice,
637            UnsupportedEntry::CharacterDevice => Self::CharacterDevice,
638            UnsupportedEntry::Unknown => Self::Unknown,
639        }
640    }
641}
642
643fn alias_error(path: &Path) -> FilesystemProjectReadError {
644    FilesystemProjectReadError::Survey(FilesystemProjectSurveyError {
645        issues: vec![FilesystemProjectIssue::Alias {
646            path: path.to_owned(),
647        }],
648    })
649}
650
651fn slash_path(path: &Path) -> Option<String> {
652    path.components()
653        .map(|component| component.as_os_str().to_str())
654        .collect::<Option<Vec<_>>>()
655        .map(|components| components.join("/"))
656}
657
658fn checked_add(
659    total: u64,
660    value: u64,
661    resource: FilesystemProjectResource,
662) -> Result<u64, FilesystemProjectLimitError> {
663    total
664        .checked_add(value)
665        .ok_or(FilesystemProjectLimitError::AccountingOverflow { resource })
666}
667
668fn check_limit(
669    resource: FilesystemProjectResource,
670    ceiling: u64,
671    observed: u64,
672) -> Result<(), FilesystemProjectLimitError> {
673    if observed > ceiling {
674        return Err(FilesystemProjectLimitError::exceeded(resource, ceiling));
675    }
676    Ok(())
677}
678
679struct ProjectIgnorePolicy {
680    rules: Gitignore,
681}
682
683impl ProjectIgnorePolicy {
684    fn built_in() -> Self {
685        Self {
686            rules: Gitignore::empty(),
687        }
688    }
689
690    fn from_bytes(bytes: &[u8]) -> Result<Self, FilesystemProjectPolicyError> {
691        let contents =
692            std::str::from_utf8(bytes).map_err(|_| FilesystemProjectPolicyError::NotUtf8)?;
693        let mut builder = GitignoreBuilder::new(".");
694        for (index, line) in contents.lines().enumerate() {
695            let line = if index == 0 {
696                line.trim_start_matches('\u{feff}')
697            } else {
698                line
699            };
700            builder.add_line(None, line).map_err(|error| {
701                FilesystemProjectPolicyError::InvalidRule {
702                    line: index + 1,
703                    message: error.to_string(),
704                }
705            })?;
706        }
707        let rules = builder
708            .build()
709            .map_err(|error| FilesystemProjectPolicyError::Invalid(error.to_string()))?;
710        Ok(Self { rules })
711    }
712
713    fn excludes_file(&self, path: &str) -> bool {
714        self.excludes(path, false)
715    }
716
717    fn excludes_directory(&self, path: &str) -> bool {
718        self.excludes(path, true)
719    }
720
721    fn excludes(&self, path: &str, is_directory: bool) -> bool {
722        if path == IGNORE_FILE {
723            return false;
724        }
725        if names_pack_path(path) {
726            return true;
727        }
728        let mut ancestor_end = 0;
729        while let Some(offset) = path[ancestor_end..].find('/') {
730            ancestor_end += offset;
731            if self.rules.matched(&path[..ancestor_end], true).is_ignore() {
732                return true;
733            }
734            ancestor_end += 1;
735        }
736        self.rules.matched(path, is_directory).is_ignore()
737    }
738}
739
740#[cfg(all(test, unix))]
741mod tests {
742    use std::os::unix::fs::symlink;
743
744    use super::*;
745
746    #[test]
747    fn bounded_reads_do_not_follow_an_alias_created_after_survey() {
748        let directory = tempfile::tempdir().unwrap();
749        let outside = directory.path().join("outside");
750        let selected = directory.path().join("selected");
751        std::fs::write(&outside, b"outside").unwrap();
752        symlink(&outside, &selected).unwrap();
753
754        let error = read_bounded(
755            directory.path(),
756            &selected,
757            &[(FilesystemProjectResource::SelectedFileBytes, 16, 16, 0)],
758            FilesystemProjectOperation::ReadSelectedFile,
759        )
760        .unwrap_err();
761
762        assert!(matches!(
763            error,
764            FilesystemProjectReadError::Survey(ref survey)
765                if matches!(survey.issues(), [FilesystemProjectIssue::Alias { path }] if path == &selected)
766        ));
767    }
768
769    #[test]
770    fn bounded_reads_do_not_follow_an_ancestor_alias_created_after_survey() {
771        let directory = tempfile::tempdir().unwrap();
772        let root = directory.path().join("project");
773        let ancestor = root.join("nested");
774        let selected = ancestor.join("selected");
775        let outside = directory.path().join("outside");
776        std::fs::create_dir_all(&ancestor).unwrap();
777        std::fs::create_dir_all(&outside).unwrap();
778        std::fs::write(&selected, b"surveyed").unwrap();
779        std::fs::write(outside.join("selected"), b"outside").unwrap();
780        std::fs::remove_dir_all(&ancestor).unwrap();
781        symlink(&outside, &ancestor).unwrap();
782
783        let error = read_bounded(
784            &root,
785            &selected,
786            &[(FilesystemProjectResource::SelectedFileBytes, 16, 16, 0)],
787            FilesystemProjectOperation::ReadSelectedFile,
788        )
789        .unwrap_err();
790
791        assert!(matches!(
792            error,
793            FilesystemProjectReadError::Survey(ref survey)
794                if matches!(survey.issues(), [FilesystemProjectIssue::Alias { path }] if path == &ancestor)
795        ));
796    }
797
798    #[test]
799    fn bounded_reads_do_not_block_on_a_fifo_created_after_survey() {
800        use std::ffi::CString;
801        use std::os::unix::ffi::OsStrExt;
802
803        let directory = tempfile::tempdir().unwrap();
804        let selected = directory.path().join("selected");
805        std::fs::write(&selected, b"surveyed").unwrap();
806        std::fs::remove_file(&selected).unwrap();
807        let path = CString::new(selected.as_os_str().as_bytes()).unwrap();
808        // SAFETY: `path` is a valid NUL-terminated filesystem path.
809        assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
810
811        let error = read_bounded(
812            directory.path(),
813            &selected,
814            &[(FilesystemProjectResource::SelectedFileBytes, 16, 16, 0)],
815            FilesystemProjectOperation::ReadSelectedFile,
816        )
817        .unwrap_err();
818
819        assert!(matches!(
820            error,
821            FilesystemProjectReadError::Survey(ref survey)
822                if matches!(survey.issues(), [FilesystemProjectIssue::UnsupportedEntry {
823                    path,
824                    kind: FilesystemProjectEntryKind::Fifo,
825                }] if path == &selected)
826        ));
827    }
828}