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
//! Deletes inside the working tree that lose no work: a build output
//! directory, a scratch directory at the top of the tree, or a `find`
//! cleanup of files a tool writes again. The static guard allows these, as
//! `ljos-policyd` does, so a stock seat with no rules of its own does not
//! stop `rm -rf target/` or `find . -name '*.pyc' -delete`. The lists and
//! the reading are the ones in ljos-policyd's `src/lib.rs`; keep the two in
//! step.
/// Directories a build, a package manager or a test run writes and can
/// write again.
const BUILD_OUTPUT_DIRS: &[&str] = &[
"node_modules",
"target",
"dist",
"build",
"_build",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".tox",
".nox",
".next",
".nuxt",
".svelte-kit",
".turbo",
".parcel-cache",
".angular",
".gradle",
"coverage",
"htmlcov",
];
/// Scratch directories a project keeps at its top for throwaway output.
const SCRATCH_DIRS: &[&str] = &["tmp", ".tmp", "temp"];
/// Names a compiler, an interpreter, an editor or a desktop writes and
/// writes again.
const REGENERABLE_NAMES: &[&str] = &[
"*.pyc",
"*.pyo",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
"*.o",
"*.obj",
"*.class",
"*.gcda",
"*.gcno",
"*.orig",
"*.rej",
"*.swp",
"*.swo",
"*~",
".DS_Store",
"._*",
"Thumbs.db",
"desktop.ini",
];
/// A path written out in plain characters that stays below the working
/// directory: not absolute, no `~`, no `$`, no glob, no `..` and no `.`
/// part. A leading `./` and a trailing `/` are allowed.
fn plain_relative(p: &str) -> bool {
let mut rest = p;
while let Some(r) = rest.strip_prefix("./") {
rest = r;
}
let rest = rest.trim_end_matches('/');
!rest.is_empty()
&& !rest.starts_with('-')
&& rest
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@' | '/'))
&& rest
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
/// A plain relative path inside a build output directory, or the
/// directory itself: `target/`, `./build`, `web/node_modules`. A path
/// through `.git` is not one.
#[must_use]
pub fn build_output(p: &str) -> bool {
plain_relative(p)
&& !p.split('/').any(|part| part == ".git")
&& p.split('/').any(|part| BUILD_OUTPUT_DIRS.contains(&part))
}
/// A plain relative path under a scratch directory at the top of the tree,
/// or the directory itself: `tmp/`, `./tmp/test-output`. A scratch name
/// deeper down (`src/tmp`) says nothing about what the path holds.
#[must_use]
pub fn scratch(p: &str) -> bool {
let mut rest = p;
while let Some(r) = rest.strip_prefix("./") {
rest = r;
}
plain_relative(p)
&& rest
.split('/')
.next()
.is_some_and(|first| SCRATCH_DIRS.contains(&first))
&& !rest.split('/').any(|part| part == ".git")
}
/// A `cd`, `pushd` or `popd` that can leave the working tree: an absolute
/// target, `~`, `-`, `..`, a variable, or none at all (home). `words` is
/// one command, wrappers already stripped.
#[must_use]
pub fn leaves_tree(words: &[String]) -> bool {
let Some(first) = words.first() else {
return false;
};
let name = first.rsplit('/').next().unwrap_or(first);
if name == "popd" {
return true;
}
if name != "cd" && name != "pushd" {
return false;
}
match words[1..].iter().find(|a| !a.starts_with('-') || *a == "-") {
Some(t) => !plain_relative(t),
None => true,
}
}
/// A `find` cleanup inside the working tree: every starting point is `.`
/// or a plain relative path outside `.git`, and every name test names a
/// file in [`REGENERABLE_NAMES`], with at least one. An `-o`, a `!`, a
/// `-not` or a group can turn a name test around, so none passes.
/// `find . -delete` has no name test and `find . -name '*.rs' -delete`
/// names source, so both stay refused.
#[must_use]
pub fn regenerable_cleanup(starts: &[&str], expr: &[&str]) -> bool {
let in_tree = |p: &&str| {
let p = p.trim_end_matches('/');
(p == "." || plain_relative(p)) && !p.split('/').any(|part| part == ".git")
};
if !starts.iter().all(in_tree) {
return false;
}
if expr.iter().any(|a| {
matches!(
*a,
"-o" | "-or" | "!" | "-not" | "," | "(" | ")" | "\\(" | "\\)"
)
}) {
return false;
}
let mut named = 0;
for w in expr.windows(2) {
match w[0] {
"-name" | "-iname" => {
if !REGENERABLE_NAMES.contains(&w[1]) {
return false;
}
named += 1;
}
"-path" | "-ipath" | "-wholename" => {
let parts: Vec<&str> = w[1].split('/').filter(|p| *p != "*" && *p != ".").collect();
if parts.is_empty() || !parts.iter().all(|p| REGENERABLE_NAMES.contains(p)) {
return false;
}
named += 1;
}
"-regex" | "-iregex" | "-lname" | "-ilname" => return false,
_ => {}
}
}
named > 0
}