1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
//! Confine the files that `--save-baseline`, `--save-regression-baseline`,
//! `--save-snapshot`, `--output-file` and `--sarif-file` write to the project,
//! and keep the default cache directory inside it.
//!
//! A relative path resolves against the working directory, as it always did,
//! so the matching read path (`--baseline`, `--regression-baseline`) still
//! finds the file. The resolved path must then lie inside the project root, or
//! inside the Git work tree that contains the root when the working directory
//! is inside that tree too, or inside a shared directory (`GITHUB_WORKSPACE`,
//! `CI_PROJECT_DIR` and `RUNNER_TEMP` when they are set, and the system temp
//! directory). An existing character device or named pipe is allowed too.
//! The default destinations of a bare `--save-snapshot` and a bare
//! `--save-regression-baseline` are checked the same way.
//! The work tree keeps the monorepo form working, where a job runs from the
//! repository root with `--root packages/app` and saves to a
//! repository-relative path. The CI workspace keeps the Action form working
//! where the repository is checked out into a subdirectory. The temp
//! directories keep the CI form working, where a job saves a baseline outside
//! the checkout between steps.
//!
//! The check runs before the analysis. It then records the scope in
//! [`fallow_engine::write_guard`], and each writer checks the path again right
//! before the write, without following a symlink at the final component.
//! That second check narrows the window for a swapped path component to the
//! moment of the write. It does not close it for an intermediate directory.
use std::path::{Path, PathBuf};
use fallow_engine::write_guard::{self, WriteScope, WriteTarget};
use crate::{Cli, Command};
/// Return an error message when a file that a save or report flag writes
/// resolves outside the allowed directories. When the scope can be built, it
/// is also recorded for the check that each writer runs before the write.
///
/// The save flags are checked only for the commands that write them. A
/// command that rejects the flags, such as `audit`, keeps its own error about
/// the flag. `--output-file` is checked for every command that reaches this
/// point, and `--sarif-file` for the commands that write it.
/// The check fails closed: when the working directory or the root cannot be
/// resolved, a write is rejected.
pub fn write_path_error(cli: &Cli, root: &Path) -> Option<String> {
let targets = write_targets(cli, root);
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(err) => {
let (first_flag, _, _) = targets.first()?;
return Some(format!(
"{first_flag} cannot be checked, because the working directory cannot be read ({err}). Run fallow from an existing directory."
));
}
};
let shared = write_guard::shared_dirs();
let scopes = WriteScope::new(root, &cwd, shared.clone()).and_then(|scope| {
// The discovered config file is the file fallow reads for this
// project, so the Git work tree of the root counts for it wherever
// the run starts. Its resolved path is still checked, so a config
// symlink that points outside stays rejected.
WriteScope::new(root, root, shared).map(|config_scope| (scope, config_scope))
});
let (scope, config_scope) = match scopes {
Ok(scopes) => scopes,
Err(message) => {
let (first_flag, _, _) = targets.first()?;
return Some(format!("{first_flag} cannot be checked: {message}"));
}
};
let error = targets
.iter()
.find_map(|(flag, path, target)| match target {
WriteTarget::DiscoveredConfig => config_scope.check(flag, path, &cwd),
WriteTarget::Path => scope.check(flag, path, &cwd),
});
if error.is_none() {
write_guard::confine(cwd, scope, config_scope);
}
error
}
/// When the default cache directory `<root>/.fallow` resolves outside the
/// project, for example through a committed symlink, return a note and do not
/// use it for the cache. `FALLOW_CACHE_DIR` and `--no-cache` skip the check,
/// because the default directory is then not used for the cache.
pub fn default_cache_dir_note(cli: &Cli, root: &Path) -> Option<String> {
if cli.no_cache || crate::runtime_support::resolve_cache_dir_env().is_some() {
return None;
}
let cache_dir = root.join(".fallow");
cache_dir.symlink_metadata().ok()?;
let cwd = std::env::current_dir().ok()?;
let resolved = write_guard::resolve(&cache_dir);
let inside = WriteScope::new(root, &cwd, write_guard::shared_dirs())
.is_ok_and(|scope| scope.contains(&resolved));
(!inside).then(|| {
format!(
"note: {} resolves to {}, which is outside the project, so it is not used for the cache in this run. Remove the link, or set FALLOW_CACHE_DIR or cache.dir to relocate the cache.",
cache_dir.display(),
resolved.display()
)
})
}
/// Every file on the command line that a save or report flag writes, with
/// the flag that asked for it.
fn write_targets(cli: &Cli, root: &Path) -> Vec<(&'static str, PathBuf, WriteTarget)> {
let mut targets = if matches!(
cli.command,
None | Some(Command::Check { .. } | Command::Dupes { .. } | Command::Health { .. })
) {
save_targets(cli, root)
} else {
Vec::new()
};
// `--output-file` is opened before any command runs. `--sarif-file` is
// written only by the commands below; another command either ignores it
// or rejects it with its own error, which stays the error the user sees.
let writes_sarif = matches!(
cli.command,
None | Some(
Command::Check { .. }
| Command::Security {
subcommand: None,
..
}
)
);
for (flag, path) in [
("--output-file", cli.output_file.as_deref()),
(
"--sarif-file",
cli.sarif_file.as_deref().filter(|_| writes_sarif),
),
] {
if let Some(path) = path.filter(|path| !path.as_os_str().is_empty()) {
targets.push((flag, path.to_path_buf(), WriteTarget::Path));
}
}
targets
}
/// Every file that a save flag writes.
///
/// An explicit path is checked as given. A bare `--save-snapshot` writes into
/// `<root>/.fallow/snapshots`, and a bare `--save-regression-baseline`
/// rewrites the config file, so their default destinations are checked too:
/// a committed `.fallow` or config symlink must not carry the write out of
/// the project.
fn save_targets(cli: &Cli, root: &Path) -> Vec<(&'static str, PathBuf, WriteTarget)> {
let mut targets = Vec::new();
if let Some(path) = cli.save_baseline.as_deref() {
targets.push(("--save-baseline", path.to_path_buf(), WriteTarget::Path));
}
if let Some(value) = cli.save_regression_baseline.as_ref() {
match value.as_deref().filter(|path| !path.is_empty()) {
Some(path) => targets.push((
"--save-regression-baseline",
PathBuf::from(path),
WriteTarget::Path,
)),
None => targets.push((
"--save-regression-baseline",
crate::regression::regression_config_target(cli.config.as_deref(), root),
if cli.config.is_some() {
WriteTarget::Path
} else {
WriteTarget::DiscoveredConfig
},
)),
}
}
let health_snapshot = match cli.command.as_ref() {
Some(Command::Health { save_snapshot, .. }) => save_snapshot.as_ref(),
_ => None,
};
for snapshot in [cli.save_snapshot.as_ref(), health_snapshot]
.into_iter()
.flatten()
{
match snapshot.as_deref().filter(|path| !path.is_empty()) {
Some(path) => targets.push(("--save-snapshot", PathBuf::from(path), WriteTarget::Path)),
None => targets.push((
"--save-snapshot",
root.join(".fallow").join("snapshots").join("snapshot.json"),
WriteTarget::Path,
)),
}
}
targets.retain(|(_, path, _)| !path.as_os_str().is_empty());
targets
}