1use std::fs::File;
31use std::io::{self, Write};
32use std::path::{Component, Path, PathBuf};
33use std::sync::OnceLock;
34
35#[derive(Debug, Clone)]
37pub struct WriteScope {
38 root: PathBuf,
39 work_tree: Option<PathBuf>,
40 shared_dirs: Vec<PathBuf>,
41}
42
43impl WriteScope {
44 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 #[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 #[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
114const SHARED_DIR_VARIABLES: [&str; 3] = ["GITHUB_WORKSPACE", "CI_PROJECT_DIR", "RUNNER_TEMP"];
118
119#[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#[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#[cfg(windows)]
154const WINDOWS_NULL_DEVICE: &str = r"\\.\NUL";
155
156fn is_null_device(path: &Path) -> bool {
161 cfg!(windows) && names_windows_null_device(path)
162}
163
164fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum WriteTarget {
185 Path,
187 DiscoveredConfig,
190}
191
192#[derive(Debug)]
194struct Confinement {
195 cwd: PathBuf,
196 paths: WriteScope,
197 config: WriteScope,
198}
199
200static CONFINEMENT: OnceLock<Confinement> = OnceLock::new();
201
202pub fn confine(cwd: PathBuf, paths: WriteScope, config: WriteScope) {
207 let _ = CONFINEMENT.set(Confinement { cwd, paths, config });
208}
209
210#[derive(Debug)]
212pub enum WriteFailure {
213 Directory(io::Error),
215 File(io::Error),
219}
220
221impl WriteFailure {
222 #[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
248pub 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
272pub 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 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
326fn 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#[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#[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#[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
378fn 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 #[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
407const MAX_LINK_HOPS: usize = 40;
409
410#[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
442fn 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 #[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 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 #[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 #[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 #[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(®ular, "keep").unwrap();
668 assert!(open_stream(®ular).is_err(), "a regular file is refused");
669 let handle = std::fs::File::open(®ular).unwrap();
670 assert!(ensure_stream_handle(&handle, ®ular).is_err());
671 assert_eq!(std::fs::read_to_string(®ular).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 #[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 #[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 #[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}