Skip to main content

fallow_engine/
write_guard.rs

1//! Confine the files that a run writes on request (baselines, snapshots and
2//! report files) to the project.
3//!
4//! A path must resolve inside the project root, or inside the Git work tree
5//! that contains the root when the working directory is inside that tree too,
6//! or inside a shared directory: the CI workspace (`GITHUB_WORKSPACE`, GitLab
7//! `CI_PROJECT_DIR`) and the temp directories (`RUNNER_TEMP`, the system temp
8//! directory), each when it is set. An existing character device or named
9//! pipe (`/dev/null`, `/dev/stdout`, process substitution) is also allowed,
10//! because a write to it cannot put a file anywhere. On Windows the null
11//! device (`NUL`) is allowed for the same reason.
12//!
13//! The command line layer checks each path before the analysis starts and
14//! then records the scope with [`confine`]. The writers call [`create_file`]
15//! or [`write_file`], which resolve and check the path again right before the
16//! write and do not follow a symlink at the final component.
17//!
18//! This narrows the window for a path that another local user swaps for a
19//! symlink, from the whole analysis to the moment of the write. It does not
20//! close the window for an intermediate directory: a directory swapped
21//! between the last parent check and the open call is still followed. On
22//! Windows the final component is checked just before the open, so a small
23//! window stays there too.
24//!
25//! Symlinks are resolved on both sides: on the part of the path that exists,
26//! and on each allowed directory. So a link inside the root that points
27//! outside it does not open a way out, and `/var` and `/private/var` on macOS
28//! compare equal.
29
30use std::fs::File;
31use std::io::{self, Write};
32use std::path::{Component, Path, PathBuf};
33use std::sync::OnceLock;
34
35/// The directories a run may write into.
36#[derive(Debug, Clone)]
37pub struct WriteScope {
38    root: PathBuf,
39    work_tree: Option<PathBuf>,
40    shared_dirs: Vec<PathBuf>,
41}
42
43impl WriteScope {
44    /// Build the scope for a project root.
45    ///
46    /// The Git work tree that contains the root counts only when `cwd` is
47    /// inside it too. That is the monorepo case the allowance exists for. It
48    /// keeps a Git repository at `$HOME` (a dotfiles setup) from allowing
49    /// every home path to a run started outside that tree.
50    ///
51    /// # Errors
52    ///
53    /// Returns a message when the root cannot be resolved, so a caller fails
54    /// closed.
55    pub fn new(root: &Path, cwd: &Path, shared_dirs: Vec<PathBuf>) -> Result<Self, String> {
56        let root = root.canonicalize().map_err(|err| {
57            format!(
58                "the project root {} cannot be resolved ({err})",
59                root.display()
60            )
61        })?;
62        let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
63        let work_tree = root
64            .ancestors()
65            .find(|dir| dir.join(".git").exists())
66            .filter(|tree| cwd.starts_with(tree))
67            .map(Path::to_path_buf);
68        Ok(Self {
69            root,
70            work_tree,
71            shared_dirs,
72        })
73    }
74
75    /// Whether a resolved path lies inside one of the allowed directories, or
76    /// is an existing character device or named pipe.
77    #[must_use]
78    pub fn contains(&self, path: &Path) -> bool {
79        is_stream_target(path)
80            || path.starts_with(&self.root)
81            || self
82                .work_tree
83                .as_deref()
84                .is_some_and(|tree| path.starts_with(tree))
85            || self.shared_dirs.iter().any(|dir| path.starts_with(dir))
86    }
87
88    /// Return an error message when `path`, relative to `cwd`, resolves
89    /// outside the allowed directories. `flag` names the option that asked
90    /// for the write.
91    #[must_use]
92    pub fn check(&self, flag: &str, path: &Path, cwd: &Path) -> Option<String> {
93        if is_null_device(path) {
94            return None;
95        }
96        let resolved = resolve(&cwd.join(path));
97        if self.contains(&resolved) {
98            return None;
99        }
100        Some(format!(
101            "{flag} {} resolves to {}, which is outside the project root {}. Choose a path inside the project root{}, or inside the CI workspace (GITHUB_WORKSPACE or CI_PROJECT_DIR) or the temp directory (RUNNER_TEMP or the system temp directory).",
102            path.display(),
103            resolved.display(),
104            self.root.display(),
105            self.work_tree
106                .as_deref()
107                .filter(|tree| *tree != self.root)
108                .map(|tree| format!(" or its Git work tree {}", tree.display()))
109                .unwrap_or_default(),
110        ))
111    }
112}
113
114/// The environment variables that name a shared directory a run may write
115/// into: the GitHub Actions workspace and temp directory, and the GitLab
116/// project directory.
117const SHARED_DIR_VARIABLES: [&str; 3] = ["GITHUB_WORKSPACE", "CI_PROJECT_DIR", "RUNNER_TEMP"];
118
119/// The shared directories a run may write into: each directory in
120/// `GITHUB_WORKSPACE`, `CI_PROJECT_DIR` and `RUNNER_TEMP` that is set and not
121/// empty, and the system temp directory. The CI workspace keeps a job working that checks the
122/// repository out into a subdirectory and writes its report beside it. A
123/// directory that does not exist is skipped, because it cannot be resolved.
124#[must_use]
125pub fn shared_dirs() -> Vec<PathBuf> {
126    SHARED_DIR_VARIABLES
127        .iter()
128        .filter_map(std::env::var_os)
129        .filter(|value| !value.is_empty())
130        .map(PathBuf::from)
131        .chain(std::iter::once(std::env::temp_dir()))
132        .filter_map(|dir| dir.canonicalize().ok())
133        .collect()
134}
135
136/// Whether `path` is an existing character device or named pipe. A write to
137/// one cannot create or replace a file, so it needs no confinement.
138#[cfg(unix)]
139fn is_stream_target(path: &Path) -> bool {
140    use std::os::unix::fs::FileTypeExt;
141    std::fs::metadata(path).is_ok_and(|meta| {
142        let file_type = meta.file_type();
143        file_type.is_char_device() || file_type.is_fifo()
144    })
145}
146
147#[cfg(not(unix))]
148const fn is_stream_target(_path: &Path) -> bool {
149    false
150}
151
152/// The device path of the Windows null device.
153#[cfg(windows)]
154const WINDOWS_NULL_DEVICE: &str = r"\\.\NUL";
155
156/// Whether `path`, as the command line gave it, names the null device on
157/// this platform. Only Windows has a device name that the path check must
158/// know: the resolved path of `NUL` is a normal path in the working
159/// directory, where the device name no longer applies.
160fn is_null_device(path: &Path) -> bool {
161    cfg!(windows) && names_windows_null_device(path)
162}
163
164/// Whether `path` names the Windows null device: `NUL` alone, in any case,
165/// with an optional colon, or the device path `\\.\NUL`.
166///
167/// A name with an extension (`NUL.txt`) and `NUL` inside a directory are not
168/// included. Windows versions do not agree on them, so they stay normal
169/// paths and get the normal check.
170fn names_windows_null_device(path: &Path) -> bool {
171    let Some(text) = path.to_str() else {
172        return false;
173    };
174    let name = text
175        .strip_prefix(r"\\.\")
176        .or_else(|| text.strip_prefix("//./"))
177        .unwrap_or_else(|| text.strip_suffix(':').unwrap_or(text));
178    name.eq_ignore_ascii_case("NUL")
179}
180
181/// What kind of file a write targets. The kind selects the scope that the
182/// write is checked against.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum WriteTarget {
185    /// A path from the command line, or a default destination in the root.
186    Path,
187    /// The config file that fallow discovered and read for the project. The
188    /// Git work tree of the root counts for it wherever the run starts.
189    DiscoveredConfig,
190}
191
192/// The scopes recorded for this process.
193#[derive(Debug)]
194struct Confinement {
195    cwd: PathBuf,
196    paths: WriteScope,
197    config: WriteScope,
198}
199
200static CONFINEMENT: OnceLock<Confinement> = OnceLock::new();
201
202/// Record the scopes that later writes are checked against. The first call
203/// wins. Without a call, writes are not confined, but they still do not
204/// follow a symlink at the final component that appears after the path was
205/// resolved.
206pub fn confine(cwd: PathBuf, paths: WriteScope, config: WriteScope) {
207    let _ = CONFINEMENT.set(Confinement { cwd, paths, config });
208}
209
210/// Why a confined write did not happen.
211#[derive(Debug)]
212pub enum WriteFailure {
213    /// A missing parent directory could not be created.
214    Directory(io::Error),
215    /// The file could not be created or written, or the path resolved
216    /// outside the recorded scope, or a path component changed after the
217    /// check.
218    File(io::Error),
219}
220
221impl WriteFailure {
222    /// Whether the failure happened while the parent directories were
223    /// created, so nothing was written.
224    #[must_use]
225    pub const fn is_directory(&self) -> bool {
226        matches!(self, Self::Directory(_))
227    }
228}
229
230impl std::fmt::Display for WriteFailure {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match self {
233            Self::Directory(error) | Self::File(error) => error.fmt(f),
234        }
235    }
236}
237
238impl std::error::Error for WriteFailure {}
239
240impl From<WriteFailure> for io::Error {
241    fn from(failure: WriteFailure) -> Self {
242        match failure {
243            WriteFailure::Directory(error) | WriteFailure::File(error) => error,
244        }
245    }
246}
247
248/// Create or truncate `path` for writing, after the checks this module
249/// describes. Missing parent directories are created.
250///
251/// # Errors
252///
253/// Returns [`WriteFailure::Directory`] when a parent directory cannot be
254/// created. Returns [`WriteFailure::File`] with `PermissionDenied` when the
255/// path resolves outside the recorded scope, or when a path component changed
256/// while the parent directories were created, and with the error of the open
257/// call when the final component became a symlink after the path was resolved
258/// (Unix) or the file cannot be created.
259pub fn create_file(path: &Path, target: WriteTarget) -> Result<File, WriteFailure> {
260    let confinement = CONFINEMENT.get();
261    let absolute = match confinement {
262        Some(confinement) => confinement.cwd.join(path),
263        None => std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)),
264    };
265    let scope = confinement.map(|confinement| match target {
266        WriteTarget::Path => &confinement.paths,
267        WriteTarget::DiscoveredConfig => &confinement.config,
268    });
269    create_checked(path, &absolute, scope)
270}
271
272/// Write `contents` to `path` through [`create_file`].
273///
274/// # Errors
275///
276/// Returns the errors of [`create_file`], and a write or flush error as
277/// [`WriteFailure::File`].
278pub fn write_file(path: &Path, contents: &[u8], target: WriteTarget) -> Result<(), WriteFailure> {
279    let mut file = create_file(path, target)?;
280    file.write_all(contents).map_err(WriteFailure::File)?;
281    file.flush().map_err(WriteFailure::File)
282}
283
284fn create_checked(
285    requested: &Path,
286    absolute: &Path,
287    scope: Option<&WriteScope>,
288) -> Result<File, WriteFailure> {
289    if is_null_device(requested) {
290        return open_null_device().map_err(WriteFailure::File);
291    }
292    let resolved = resolve(absolute);
293    if is_stream_target(&resolved) {
294        return open_stream(&resolved).map_err(WriteFailure::File);
295    }
296    if scope.is_some_and(|scope| !scope.contains(&resolved)) {
297        return Err(WriteFailure::File(io::Error::new(
298            io::ErrorKind::PermissionDenied,
299            format!(
300                "{} resolves to {}, which is outside the directories this run may write to",
301                requested.display(),
302                resolved.display()
303            ),
304        )));
305    }
306    if let Some(parent) = resolved.parent()
307        && !parent.as_os_str().is_empty()
308    {
309        std::fs::create_dir_all(parent).map_err(WriteFailure::Directory)?;
310        // A missing component can become a symlink between the resolve and
311        // the create. The parent must still resolve to the same directory.
312        let real_parent = parent.canonicalize().map_err(WriteFailure::File)?;
313        if real_parent != parent {
314            return Err(WriteFailure::File(io::Error::new(
315                io::ErrorKind::PermissionDenied,
316                format!(
317                    "{} changed while fallow prepared the write, so fallow did not write it",
318                    requested.display()
319                ),
320            )));
321        }
322    }
323    open_no_follow(&resolved).map_err(WriteFailure::File)
324}
325
326/// Open an existing character device or named pipe for writing. It is not
327/// created and not truncated. On Unix the open does not follow a symlink at
328/// the final component, and the opened handle must still be a character
329/// device or named pipe, so a path swapped after the check is refused.
330fn open_stream(path: &Path) -> io::Result<File> {
331    let mut options = std::fs::OpenOptions::new();
332    options.write(true);
333    #[cfg(unix)]
334    {
335        use std::os::unix::fs::OpenOptionsExt;
336        options.custom_flags(libc::O_NOFOLLOW);
337    }
338    let file = options.open(path)?;
339    #[cfg(unix)]
340    ensure_stream_handle(&file, path)?;
341    Ok(file)
342}
343
344/// Open the Windows null device for writing through its device path.
345#[cfg(windows)]
346fn open_null_device() -> io::Result<File> {
347    std::fs::OpenOptions::new()
348        .write(true)
349        .open(WINDOWS_NULL_DEVICE)
350}
351
352/// Only Windows has a null device name that [`is_null_device`] accepts.
353#[cfg(not(windows))]
354fn open_null_device() -> io::Result<File> {
355    Err(io::Error::new(
356        io::ErrorKind::Unsupported,
357        "only Windows has a null device name",
358    ))
359}
360
361/// Refuse a handle that is not a character device or named pipe.
362#[cfg(unix)]
363fn ensure_stream_handle(file: &File, path: &Path) -> io::Result<()> {
364    use std::os::unix::fs::FileTypeExt;
365    let file_type = file.metadata()?.file_type();
366    if file_type.is_char_device() || file_type.is_fifo() {
367        return Ok(());
368    }
369    Err(io::Error::new(
370        io::ErrorKind::PermissionDenied,
371        format!(
372            "{} is no longer a device or a named pipe, so fallow did not write it",
373            path.display()
374        ),
375    ))
376}
377
378/// Open `path` for writing without following a symlink at the final
379/// component. On Unix the open call refuses the link itself. Elsewhere the
380/// final component is checked right before the open.
381fn open_no_follow(path: &Path) -> io::Result<File> {
382    let mut options = std::fs::OpenOptions::new();
383    options.write(true).create(true).truncate(true);
384    #[cfg(unix)]
385    {
386        use std::os::unix::fs::OpenOptionsExt;
387        options.custom_flags(libc::O_NOFOLLOW);
388    }
389    // This check and the open are two steps, so a link that appears between
390    // them is still followed. Only the Unix open closes that window.
391    #[cfg(not(unix))]
392    if path
393        .symlink_metadata()
394        .is_ok_and(|meta| meta.file_type().is_symlink())
395    {
396        return Err(io::Error::new(
397            io::ErrorKind::PermissionDenied,
398            format!(
399                "{} became a symlink after fallow checked it, so fallow did not write it",
400                path.display()
401            ),
402        ));
403    }
404    options.open(path)
405}
406
407/// How many dangling symlinks [`resolve`] follows before it gives up.
408const MAX_LINK_HOPS: usize = 40;
409
410/// Resolve `path` the way a write would reach it: symlinks and `..` in the
411/// part that exists are resolved by the file system, and the rest, which a
412/// write would create, is normalised lexically. A dangling symlink is followed
413/// to its target, because a write through it lands there.
414#[must_use]
415pub fn resolve(path: &Path) -> PathBuf {
416    resolve_with_hops(path, 0)
417}
418
419fn resolve_with_hops(path: &Path, hops: usize) -> PathBuf {
420    let mut existing = path;
421    let mut missing = Vec::new();
422    loop {
423        if let Ok(real) = existing.canonicalize() {
424            return append_missing(real, &missing);
425        }
426        if hops < MAX_LINK_HOPS
427            && let Ok(target) = std::fs::read_link(existing)
428        {
429            let base = existing.parent().unwrap_or_else(|| Path::new(""));
430            let followed = resolve_with_hops(&base.join(target), hops + 1);
431            return append_missing(followed, &missing);
432        }
433        let (Some(parent), Some(last)) = (existing.parent(), existing.components().next_back())
434        else {
435            return append_missing(PathBuf::new(), &missing);
436        };
437        missing.push(last);
438        existing = parent;
439    }
440}
441
442/// Append the components that do not exist yet, innermost last.
443fn append_missing(mut resolved: PathBuf, missing: &[Component<'_>]) -> PathBuf {
444    for component in missing.iter().rev() {
445        match component {
446            Component::ParentDir => {
447                resolved.pop();
448            }
449            Component::CurDir => {}
450            other => resolved.push(other.as_os_str()),
451        }
452    }
453    resolved
454}
455
456#[cfg(test)]
457mod tests {
458    use std::path::Path;
459
460    use super::{WriteScope, create_checked, names_windows_null_device, resolve};
461
462    /// The Windows null device is named by `NUL` alone, in any case, with an
463    /// optional colon, or by its device path. A file name that only starts
464    /// with `NUL`, or `NUL` in a directory, is a normal file name.
465    #[test]
466    fn the_windows_null_device_is_named_by_nul_alone() {
467        for name in [
468            "NUL", "nul", "Nul", "NUL:", r"\\.\NUL", r"\\.\nul", "//./NUL",
469        ] {
470            assert!(names_windows_null_device(Path::new(name)), "{name}");
471        }
472        for name in [
473            "NUL.txt",
474            "nul.sarif",
475            "null",
476            "NULL",
477            "report",
478            r"dir\NUL",
479            "dir/nul",
480            r".\NUL",
481            r"C:\NUL",
482            "",
483        ] {
484            assert!(!names_windows_null_device(Path::new(name)), "{name}");
485        }
486    }
487
488    #[test]
489    fn resolve_normalises_the_missing_part() {
490        let dir = tempfile::tempdir().expect("temp dir");
491        let base = dir.path().canonicalize().unwrap();
492        assert_eq!(
493            resolve(&base.join("a/b/../c.json")),
494            base.join("a").join("c.json")
495        );
496        assert_eq!(
497            resolve(&base.join("a/../../x.json")),
498            base.parent().unwrap().join("x.json")
499        );
500    }
501
502    #[cfg(unix)]
503    #[test]
504    fn a_dangling_link_resolves_to_its_target() {
505        let dir = tempfile::tempdir().expect("temp dir");
506        let base = dir.path().canonicalize().unwrap();
507        let root = base.join("project");
508        std::fs::create_dir_all(&root).unwrap();
509        std::os::unix::fs::symlink(base.join("elsewhere.json"), root.join("b.json")).unwrap();
510        assert_eq!(resolve(&root.join("b.json")), base.join("elsewhere.json"));
511    }
512
513    #[test]
514    fn scope_accepts_a_temp_dir_through_a_symlinked_spelling() {
515        let dir = tempfile::tempdir().expect("temp dir");
516        let root = dir.path().join("project");
517        let temp = dir.path().join("temp");
518        std::fs::create_dir_all(&root).unwrap();
519        std::fs::create_dir_all(&temp).unwrap();
520        let scope =
521            WriteScope::new(&root, &root, vec![temp.canonicalize().unwrap()]).expect("scope");
522        // `dir.path()` is not canonical on macOS (`/var` links to
523        // `/private/var`), so this also checks that both sides are resolved.
524        assert!(
525            scope
526                .check("--save-baseline", &temp.join("b.json"), &root)
527                .is_none()
528        );
529        assert!(
530            scope
531                .check("--save-baseline", &dir.path().join("b.json"), &root)
532                .is_some()
533        );
534    }
535
536    #[test]
537    fn a_root_that_cannot_be_resolved_fails_closed() {
538        let dir = tempfile::tempdir().expect("temp dir");
539        let missing = dir.path().join("missing");
540        assert!(WriteScope::new(&missing, dir.path(), Vec::new()).is_err());
541    }
542
543    #[test]
544    fn the_work_tree_needs_the_working_directory_inside_it() {
545        let dir = tempfile::tempdir().expect("temp dir");
546        let home = dir.path().join("home");
547        let root = home.join("project");
548        let elsewhere = dir.path().join("elsewhere");
549        std::fs::create_dir_all(&root).unwrap();
550        std::fs::create_dir_all(&elsewhere).unwrap();
551        std::fs::create_dir_all(home.join(".git")).unwrap();
552        let target = home.join(".config").join("x.json");
553        let from_outside = WriteScope::new(&root, &elsewhere, Vec::new()).expect("scope");
554        assert!(
555            from_outside
556                .check("--save-baseline", &target, &elsewhere)
557                .is_some()
558        );
559        let from_inside = WriteScope::new(&root, &home, Vec::new()).expect("scope");
560        assert!(
561            from_inside
562                .check("--save-baseline", &target, &home)
563                .is_none()
564        );
565    }
566
567    #[test]
568    fn scope_accepts_the_root_and_rejects_a_sibling() {
569        let dir = tempfile::tempdir().expect("temp dir");
570        let root = dir.path().join("project");
571        std::fs::create_dir_all(&root).unwrap();
572        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
573        assert!(
574            scope
575                .check("--save-baseline", "nested/b.json".as_ref(), &root)
576                .is_none()
577        );
578        let message = scope
579            .check("--save-baseline", "../b.json".as_ref(), &root)
580            .expect("outside");
581        assert!(message.contains("outside the project root"), "{message}");
582    }
583
584    #[test]
585    fn scope_accepts_the_git_work_tree_of_the_root() {
586        let dir = tempfile::tempdir().expect("temp dir");
587        let repo = dir.path().join("repo");
588        let root = repo.join("packages/app");
589        std::fs::create_dir_all(&root).unwrap();
590        std::fs::create_dir_all(repo.join(".git")).unwrap();
591        let scope = WriteScope::new(&root, &repo, Vec::new()).expect("scope");
592        assert!(
593            scope
594                .check("--save-baseline", "baselines/b.json".as_ref(), &repo)
595                .is_none()
596        );
597        let message = scope
598            .check("--save-baseline", "../b.json".as_ref(), &repo)
599            .expect("outside");
600        assert!(message.contains("Git work tree"), "{message}");
601    }
602
603    #[test]
604    fn a_checked_write_lands_inside_the_scope() {
605        let dir = tempfile::tempdir().expect("temp dir");
606        let root = dir.path().join("project");
607        std::fs::create_dir_all(&root).unwrap();
608        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
609        let target = root.join("out/nested/report.json");
610        assert!(scope.check("--output-file", &target, &root).is_none());
611        let file = create_checked(&target, &target, Some(&scope)).expect("write inside");
612        drop(file);
613        assert!(target.is_file());
614    }
615
616    /// A character device is allowed and written through.
617    #[cfg(unix)]
618    #[test]
619    fn a_character_device_is_allowed_outside_the_scope() {
620        let dir = tempfile::tempdir().expect("temp dir");
621        let root = dir.path().join("project");
622        std::fs::create_dir_all(&root).unwrap();
623        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
624        let null = std::path::Path::new("/dev/null");
625        assert!(scope.check("--output-file", null, &root).is_none());
626        let file = create_checked(null, null, Some(&scope)).expect("write to /dev/null");
627        drop(file);
628    }
629
630    /// A named pipe outside the scope is allowed, and the write reaches the
631    /// reader.
632    #[cfg(unix)]
633    #[test]
634    fn a_named_pipe_is_allowed_outside_the_scope() {
635        use std::io::Write as _;
636        let dir = tempfile::tempdir().expect("temp dir");
637        let root = dir.path().join("project");
638        std::fs::create_dir_all(&root).unwrap();
639        let fifo = dir.path().join("report.fifo");
640        let made = std::process::Command::new("mkfifo")
641            .arg(&fifo)
642            .status()
643            .expect("run mkfifo");
644        assert!(made.success());
645        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
646        assert!(scope.check("--sarif-file", &fifo, &root).is_none());
647        let reader_path = fifo.clone();
648        let reader = std::thread::spawn(move || std::fs::read_to_string(reader_path));
649        let mut file = create_checked(&fifo, &fifo, Some(&scope)).expect("write to fifo");
650        file.write_all(b"sarif").unwrap();
651        drop(file);
652        assert_eq!(reader.join().unwrap().unwrap(), "sarif");
653    }
654
655    /// A stream target is refused when the path is a symlink at the open, or
656    /// when the opened handle is a regular file.
657    #[cfg(unix)]
658    #[test]
659    fn a_stream_target_swapped_after_the_check_is_refused() {
660        use super::{ensure_stream_handle, open_stream};
661        let dir = tempfile::tempdir().expect("temp dir");
662        let link = dir.path().join("null-link");
663        std::os::unix::fs::symlink("/dev/null", &link).unwrap();
664        assert!(open_stream(&link).is_err(), "a symlink is not followed");
665
666        let regular = dir.path().join("regular.json");
667        std::fs::write(&regular, "keep").unwrap();
668        assert!(open_stream(&regular).is_err(), "a regular file is refused");
669        let handle = std::fs::File::open(&regular).unwrap();
670        assert!(ensure_stream_handle(&handle, &regular).is_err());
671        assert_eq!(std::fs::read_to_string(&regular).unwrap(), "keep");
672
673        let null = std::fs::File::open("/dev/null").unwrap();
674        assert!(ensure_stream_handle(&null, std::path::Path::new("/dev/null")).is_ok());
675    }
676
677    /// A directory that passed the check and then became a symlink to a
678    /// place outside the project does not carry the write out.
679    #[cfg(unix)]
680    #[test]
681    fn a_directory_swapped_for_a_symlink_after_the_check_is_refused() {
682        let dir = tempfile::tempdir().expect("temp dir");
683        let root = dir.path().join("project");
684        let elsewhere = dir.path().join("elsewhere");
685        std::fs::create_dir_all(root.join("out")).unwrap();
686        std::fs::create_dir_all(&elsewhere).unwrap();
687        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
688        let target = root.join("out/report.json");
689        assert!(scope.check("--output-file", &target, &root).is_none());
690
691        std::fs::remove_dir(root.join("out")).unwrap();
692        std::os::unix::fs::symlink(&elsewhere, root.join("out")).unwrap();
693
694        let error = std::io::Error::from(
695            create_checked(&target, &target, Some(&scope)).expect_err("refused"),
696        );
697        assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
698        assert!(!elsewhere.join("report.json").exists());
699    }
700
701    /// A final component that became a symlink after the check is refused,
702    /// also when the link points outside the project.
703    #[cfg(unix)]
704    #[test]
705    fn a_file_swapped_for_a_symlink_after_the_check_is_refused() {
706        let dir = tempfile::tempdir().expect("temp dir");
707        let root = dir.path().join("project");
708        std::fs::create_dir_all(&root).unwrap();
709        let outside = dir.path().join("outside.json");
710        let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
711        let target = root.join("report.json");
712        assert!(scope.check("--output-file", &target, &root).is_none());
713
714        std::os::unix::fs::symlink(&outside, &target).unwrap();
715
716        assert!(create_checked(&target, &target, Some(&scope)).is_err());
717        assert!(!outside.exists());
718    }
719
720    /// The open itself refuses a symlink at the final component, which covers
721    /// a link that appears after the path was resolved.
722    #[cfg(unix)]
723    #[test]
724    fn the_open_does_not_follow_a_final_symlink() {
725        let dir = tempfile::tempdir().expect("temp dir");
726        let real = dir.path().join("real.json");
727        std::fs::write(&real, "keep").unwrap();
728        let link = dir.path().join("link.json");
729        std::os::unix::fs::symlink(&real, &link).unwrap();
730
731        assert!(super::open_no_follow(&link).is_err());
732        assert_eq!(std::fs::read_to_string(&real).unwrap(), "keep");
733    }
734}