use std::fs::File;
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct WriteScope {
root: PathBuf,
work_tree: Option<PathBuf>,
shared_dirs: Vec<PathBuf>,
}
impl WriteScope {
pub fn new(root: &Path, cwd: &Path, shared_dirs: Vec<PathBuf>) -> Result<Self, String> {
let root = root.canonicalize().map_err(|err| {
format!(
"the project root {} cannot be resolved ({err})",
root.display()
)
})?;
let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let work_tree = root
.ancestors()
.find(|dir| dir.join(".git").exists())
.filter(|tree| cwd.starts_with(tree))
.map(Path::to_path_buf);
Ok(Self {
root,
work_tree,
shared_dirs,
})
}
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
is_stream_target(path)
|| path.starts_with(&self.root)
|| self
.work_tree
.as_deref()
.is_some_and(|tree| path.starts_with(tree))
|| self.shared_dirs.iter().any(|dir| path.starts_with(dir))
}
#[must_use]
pub fn check(&self, flag: &str, path: &Path, cwd: &Path) -> Option<String> {
if is_null_device(path) {
return None;
}
let resolved = resolve(&cwd.join(path));
if self.contains(&resolved) {
return None;
}
Some(format!(
"{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).",
path.display(),
resolved.display(),
self.root.display(),
self.work_tree
.as_deref()
.filter(|tree| *tree != self.root)
.map(|tree| format!(" or its Git work tree {}", tree.display()))
.unwrap_or_default(),
))
}
}
const SHARED_DIR_VARIABLES: [&str; 3] = ["GITHUB_WORKSPACE", "CI_PROJECT_DIR", "RUNNER_TEMP"];
#[must_use]
pub fn shared_dirs() -> Vec<PathBuf> {
SHARED_DIR_VARIABLES
.iter()
.filter_map(std::env::var_os)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.chain(std::iter::once(std::env::temp_dir()))
.filter_map(|dir| dir.canonicalize().ok())
.collect()
}
#[cfg(unix)]
fn is_stream_target(path: &Path) -> bool {
use std::os::unix::fs::FileTypeExt;
std::fs::metadata(path).is_ok_and(|meta| {
let file_type = meta.file_type();
file_type.is_char_device() || file_type.is_fifo()
})
}
#[cfg(not(unix))]
const fn is_stream_target(_path: &Path) -> bool {
false
}
#[cfg(windows)]
const WINDOWS_NULL_DEVICE: &str = r"\\.\NUL";
fn is_null_device(path: &Path) -> bool {
cfg!(windows) && names_windows_null_device(path)
}
fn names_windows_null_device(path: &Path) -> bool {
let Some(text) = path.to_str() else {
return false;
};
let name = text
.strip_prefix(r"\\.\")
.or_else(|| text.strip_prefix("//./"))
.unwrap_or_else(|| text.strip_suffix(':').unwrap_or(text));
name.eq_ignore_ascii_case("NUL")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteTarget {
Path,
DiscoveredConfig,
}
#[derive(Debug)]
struct Confinement {
cwd: PathBuf,
paths: WriteScope,
config: WriteScope,
}
static CONFINEMENT: OnceLock<Confinement> = OnceLock::new();
pub fn confine(cwd: PathBuf, paths: WriteScope, config: WriteScope) {
let _ = CONFINEMENT.set(Confinement { cwd, paths, config });
}
#[derive(Debug)]
pub enum WriteFailure {
Directory(io::Error),
File(io::Error),
}
impl WriteFailure {
#[must_use]
pub const fn is_directory(&self) -> bool {
matches!(self, Self::Directory(_))
}
}
impl std::fmt::Display for WriteFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Directory(error) | Self::File(error) => error.fmt(f),
}
}
}
impl std::error::Error for WriteFailure {}
impl From<WriteFailure> for io::Error {
fn from(failure: WriteFailure) -> Self {
match failure {
WriteFailure::Directory(error) | WriteFailure::File(error) => error,
}
}
}
pub fn create_file(path: &Path, target: WriteTarget) -> Result<File, WriteFailure> {
let confinement = CONFINEMENT.get();
let absolute = match confinement {
Some(confinement) => confinement.cwd.join(path),
None => std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)),
};
let scope = confinement.map(|confinement| match target {
WriteTarget::Path => &confinement.paths,
WriteTarget::DiscoveredConfig => &confinement.config,
});
create_checked(path, &absolute, scope)
}
pub fn write_file(path: &Path, contents: &[u8], target: WriteTarget) -> Result<(), WriteFailure> {
let mut file = create_file(path, target)?;
file.write_all(contents).map_err(WriteFailure::File)?;
file.flush().map_err(WriteFailure::File)
}
fn create_checked(
requested: &Path,
absolute: &Path,
scope: Option<&WriteScope>,
) -> Result<File, WriteFailure> {
if is_null_device(requested) {
return open_null_device().map_err(WriteFailure::File);
}
let resolved = resolve(absolute);
if is_stream_target(&resolved) {
return open_stream(&resolved).map_err(WriteFailure::File);
}
if scope.is_some_and(|scope| !scope.contains(&resolved)) {
return Err(WriteFailure::File(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} resolves to {}, which is outside the directories this run may write to",
requested.display(),
resolved.display()
),
)));
}
if let Some(parent) = resolved.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(WriteFailure::Directory)?;
let real_parent = parent.canonicalize().map_err(WriteFailure::File)?;
if real_parent != parent {
return Err(WriteFailure::File(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} changed while fallow prepared the write, so fallow did not write it",
requested.display()
),
)));
}
}
open_no_follow(&resolved).map_err(WriteFailure::File)
}
fn open_stream(path: &Path) -> io::Result<File> {
let mut options = std::fs::OpenOptions::new();
options.write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
#[cfg(unix)]
ensure_stream_handle(&file, path)?;
Ok(file)
}
#[cfg(windows)]
fn open_null_device() -> io::Result<File> {
std::fs::OpenOptions::new()
.write(true)
.open(WINDOWS_NULL_DEVICE)
}
#[cfg(not(windows))]
fn open_null_device() -> io::Result<File> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"only Windows has a null device name",
))
}
#[cfg(unix)]
fn ensure_stream_handle(file: &File, path: &Path) -> io::Result<()> {
use std::os::unix::fs::FileTypeExt;
let file_type = file.metadata()?.file_type();
if file_type.is_char_device() || file_type.is_fifo() {
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} is no longer a device or a named pipe, so fallow did not write it",
path.display()
),
))
}
fn open_no_follow(path: &Path) -> io::Result<File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
#[cfg(not(unix))]
if path
.symlink_metadata()
.is_ok_and(|meta| meta.file_type().is_symlink())
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} became a symlink after fallow checked it, so fallow did not write it",
path.display()
),
));
}
options.open(path)
}
const MAX_LINK_HOPS: usize = 40;
#[must_use]
pub fn resolve(path: &Path) -> PathBuf {
resolve_with_hops(path, 0)
}
fn resolve_with_hops(path: &Path, hops: usize) -> PathBuf {
let mut existing = path;
let mut missing = Vec::new();
loop {
if let Ok(real) = existing.canonicalize() {
return append_missing(real, &missing);
}
if hops < MAX_LINK_HOPS
&& let Ok(target) = std::fs::read_link(existing)
{
let base = existing.parent().unwrap_or_else(|| Path::new(""));
let followed = resolve_with_hops(&base.join(target), hops + 1);
return append_missing(followed, &missing);
}
let (Some(parent), Some(last)) = (existing.parent(), existing.components().next_back())
else {
return append_missing(PathBuf::new(), &missing);
};
missing.push(last);
existing = parent;
}
}
fn append_missing(mut resolved: PathBuf, missing: &[Component<'_>]) -> PathBuf {
for component in missing.iter().rev() {
match component {
Component::ParentDir => {
resolved.pop();
}
Component::CurDir => {}
other => resolved.push(other.as_os_str()),
}
}
resolved
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{WriteScope, create_checked, names_windows_null_device, resolve};
#[test]
fn the_windows_null_device_is_named_by_nul_alone() {
for name in [
"NUL", "nul", "Nul", "NUL:", r"\\.\NUL", r"\\.\nul", "//./NUL",
] {
assert!(names_windows_null_device(Path::new(name)), "{name}");
}
for name in [
"NUL.txt",
"nul.sarif",
"null",
"NULL",
"report",
r"dir\NUL",
"dir/nul",
r".\NUL",
r"C:\NUL",
"",
] {
assert!(!names_windows_null_device(Path::new(name)), "{name}");
}
}
#[test]
fn resolve_normalises_the_missing_part() {
let dir = tempfile::tempdir().expect("temp dir");
let base = dir.path().canonicalize().unwrap();
assert_eq!(
resolve(&base.join("a/b/../c.json")),
base.join("a").join("c.json")
);
assert_eq!(
resolve(&base.join("a/../../x.json")),
base.parent().unwrap().join("x.json")
);
}
#[cfg(unix)]
#[test]
fn a_dangling_link_resolves_to_its_target() {
let dir = tempfile::tempdir().expect("temp dir");
let base = dir.path().canonicalize().unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(base.join("elsewhere.json"), root.join("b.json")).unwrap();
assert_eq!(resolve(&root.join("b.json")), base.join("elsewhere.json"));
}
#[test]
fn scope_accepts_a_temp_dir_through_a_symlinked_spelling() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
let temp = dir.path().join("temp");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&temp).unwrap();
let scope =
WriteScope::new(&root, &root, vec![temp.canonicalize().unwrap()]).expect("scope");
assert!(
scope
.check("--save-baseline", &temp.join("b.json"), &root)
.is_none()
);
assert!(
scope
.check("--save-baseline", &dir.path().join("b.json"), &root)
.is_some()
);
}
#[test]
fn a_root_that_cannot_be_resolved_fails_closed() {
let dir = tempfile::tempdir().expect("temp dir");
let missing = dir.path().join("missing");
assert!(WriteScope::new(&missing, dir.path(), Vec::new()).is_err());
}
#[test]
fn the_work_tree_needs_the_working_directory_inside_it() {
let dir = tempfile::tempdir().expect("temp dir");
let home = dir.path().join("home");
let root = home.join("project");
let elsewhere = dir.path().join("elsewhere");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::create_dir_all(home.join(".git")).unwrap();
let target = home.join(".config").join("x.json");
let from_outside = WriteScope::new(&root, &elsewhere, Vec::new()).expect("scope");
assert!(
from_outside
.check("--save-baseline", &target, &elsewhere)
.is_some()
);
let from_inside = WriteScope::new(&root, &home, Vec::new()).expect("scope");
assert!(
from_inside
.check("--save-baseline", &target, &home)
.is_none()
);
}
#[test]
fn scope_accepts_the_root_and_rejects_a_sibling() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
assert!(
scope
.check("--save-baseline", "nested/b.json".as_ref(), &root)
.is_none()
);
let message = scope
.check("--save-baseline", "../b.json".as_ref(), &root)
.expect("outside");
assert!(message.contains("outside the project root"), "{message}");
}
#[test]
fn scope_accepts_the_git_work_tree_of_the_root() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
let root = repo.join("packages/app");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
let scope = WriteScope::new(&root, &repo, Vec::new()).expect("scope");
assert!(
scope
.check("--save-baseline", "baselines/b.json".as_ref(), &repo)
.is_none()
);
let message = scope
.check("--save-baseline", "../b.json".as_ref(), &repo)
.expect("outside");
assert!(message.contains("Git work tree"), "{message}");
}
#[test]
fn a_checked_write_lands_inside_the_scope() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
let target = root.join("out/nested/report.json");
assert!(scope.check("--output-file", &target, &root).is_none());
let file = create_checked(&target, &target, Some(&scope)).expect("write inside");
drop(file);
assert!(target.is_file());
}
#[cfg(unix)]
#[test]
fn a_character_device_is_allowed_outside_the_scope() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
let null = std::path::Path::new("/dev/null");
assert!(scope.check("--output-file", null, &root).is_none());
let file = create_checked(null, null, Some(&scope)).expect("write to /dev/null");
drop(file);
}
#[cfg(unix)]
#[test]
fn a_named_pipe_is_allowed_outside_the_scope() {
use std::io::Write as _;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let fifo = dir.path().join("report.fifo");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.expect("run mkfifo");
assert!(made.success());
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
assert!(scope.check("--sarif-file", &fifo, &root).is_none());
let reader_path = fifo.clone();
let reader = std::thread::spawn(move || std::fs::read_to_string(reader_path));
let mut file = create_checked(&fifo, &fifo, Some(&scope)).expect("write to fifo");
file.write_all(b"sarif").unwrap();
drop(file);
assert_eq!(reader.join().unwrap().unwrap(), "sarif");
}
#[cfg(unix)]
#[test]
fn a_stream_target_swapped_after_the_check_is_refused() {
use super::{ensure_stream_handle, open_stream};
let dir = tempfile::tempdir().expect("temp dir");
let link = dir.path().join("null-link");
std::os::unix::fs::symlink("/dev/null", &link).unwrap();
assert!(open_stream(&link).is_err(), "a symlink is not followed");
let regular = dir.path().join("regular.json");
std::fs::write(®ular, "keep").unwrap();
assert!(open_stream(®ular).is_err(), "a regular file is refused");
let handle = std::fs::File::open(®ular).unwrap();
assert!(ensure_stream_handle(&handle, ®ular).is_err());
assert_eq!(std::fs::read_to_string(®ular).unwrap(), "keep");
let null = std::fs::File::open("/dev/null").unwrap();
assert!(ensure_stream_handle(&null, std::path::Path::new("/dev/null")).is_ok());
}
#[cfg(unix)]
#[test]
fn a_directory_swapped_for_a_symlink_after_the_check_is_refused() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
let elsewhere = dir.path().join("elsewhere");
std::fs::create_dir_all(root.join("out")).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
let target = root.join("out/report.json");
assert!(scope.check("--output-file", &target, &root).is_none());
std::fs::remove_dir(root.join("out")).unwrap();
std::os::unix::fs::symlink(&elsewhere, root.join("out")).unwrap();
let error = std::io::Error::from(
create_checked(&target, &target, Some(&scope)).expect_err("refused"),
);
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(!elsewhere.join("report.json").exists());
}
#[cfg(unix)]
#[test]
fn a_file_swapped_for_a_symlink_after_the_check_is_refused() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let outside = dir.path().join("outside.json");
let scope = WriteScope::new(&root, &root, Vec::new()).expect("scope");
let target = root.join("report.json");
assert!(scope.check("--output-file", &target, &root).is_none());
std::os::unix::fs::symlink(&outside, &target).unwrap();
assert!(create_checked(&target, &target, Some(&scope)).is_err());
assert!(!outside.exists());
}
#[cfg(unix)]
#[test]
fn the_open_does_not_follow_a_final_symlink() {
let dir = tempfile::tempdir().expect("temp dir");
let real = dir.path().join("real.json");
std::fs::write(&real, "keep").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(super::open_no_follow(&link).is_err());
assert_eq!(std::fs::read_to_string(&real).unwrap(), "keep");
}
}