1use crate::tool::ExecArm;
11
12pub(crate) const SUSPECT_SNIPPETS_TEXT: [&str; 5] =
16 ["rm ", "rmdir", "-delete", "git reset --hard", "git clean"];
17
18pub(crate) const SUSPECT_SNIPPETS_PYTHON: [&str; 3] = ["os.remove", "shutil.rmtree", "os.unlink"];
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DiscardForecast {
23 Clear,
24 Suspected { pattern: String },
25}
26
27fn program_basename(path: &str) -> &str {
28 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
29 name.strip_suffix(".exe").unwrap_or(name)
30}
31
32pub fn forecast(arm: &ExecArm) -> DiscardForecast {
36 match arm {
37 ExecArm::Program { path, args } => {
38 let base = program_basename(path);
39 if matches!(base, "rm" | "rmdir" | "del") {
40 return DiscardForecast::Suspected {
41 pattern: base.to_owned(),
42 };
43 }
44 let joined = args.join(" ");
45 if base == "git" && (joined.contains("reset --hard") || joined.contains("clean")) {
46 return DiscardForecast::Suspected {
47 pattern: format!("git {joined}"),
48 };
49 }
50 if base == "find" && args.iter().any(|a| a == "-delete") {
51 return DiscardForecast::Suspected {
52 pattern: "find -delete".to_owned(),
53 };
54 }
55 DiscardForecast::Clear
56 }
57 ExecArm::Python { code } => {
58 for pattern in SUSPECT_SNIPPETS_TEXT
59 .iter()
60 .chain(SUSPECT_SNIPPETS_PYTHON.iter())
61 {
62 if code.contains(pattern) {
63 return DiscardForecast::Suspected {
64 pattern: (*pattern).to_owned(),
65 };
66 }
67 }
68 if code.contains("open(") && (code.contains("'w'") || code.contains("\"w\"")) {
69 return DiscardForecast::Suspected {
70 pattern: "open(..., 'w')".to_owned(),
71 };
72 }
73 DiscardForecast::Clear
74 }
75 ExecArm::Shell { text } => {
76 for pattern in SUSPECT_SNIPPETS_TEXT.iter() {
77 if text.contains(pattern) {
78 return DiscardForecast::Suspected {
79 pattern: (*pattern).to_owned(),
80 };
81 }
82 }
83 if text.contains('>') && !text.contains(">>") {
84 return DiscardForecast::Suspected {
85 pattern: "> truncation".to_owned(),
86 };
87 }
88 DiscardForecast::Clear
89 }
90 }
91}
92
93#[cfg(test)]
94#[allow(
95 clippy::unwrap_used,
96 clippy::expect_used,
97 clippy::panic,
98 clippy::indexing_slicing,
99 reason = "test code"
100)]
101mod tests {
102 use super::super::request::DiscardRequest;
103 use super::super::request::{Discard, Restoration};
104 use super::super::verdict::{DiscardVerdict, decide};
105 use super::*;
106 use crate::address::Address;
107 use crate::budget::ByteLen;
108 use crate::locator::Locator;
109 use crate::taint::TaintSet;
110 use crate::taint::TaintSource;
111 use proptest::prelude::*;
112 fn addr(raw: &str) -> Address {
113 Address::parse(raw).unwrap()
114 }
115 fn tracked() -> Restoration {
116 Restoration::Tracked(Locator::parse(&format!("file:b/x.md@{}", "ab".repeat(20))).unwrap())
117 }
118 #[test]
122 fn a_planned_discard_reads_back_what_it_was_made_with() {
123 let paths = vec![addr("b/x.md"), addr("b/y.md")];
124 let taint = TaintSet::of(TaintSource::new("web:example.com").unwrap());
125 let made =
126 Discard::new(paths.clone(), tracked(), taint.clone(), ByteLen::new(4096)).unwrap();
127 assert_eq!(
128 (made.paths(), made.plan(), made.taint(), made.total_bytes()),
129 (paths.as_slice(), &tracked(), &taint, ByteLen::new(4096))
130 );
131 }
132
133 #[test]
136 fn git_discards_by_either_command_and_only_git_does() {
137 let arm = |path: &str, args: &[&str]| ExecArm::Program {
138 path: path.into(),
139 args: args.iter().map(|arg| (*arg).to_owned()).collect(),
140 };
141 assert_eq!(
142 forecast(&arm("git", &["clean", "-fd"])),
143 DiscardForecast::Suspected {
144 pattern: "git clean -fd".to_owned()
145 }
146 );
147 assert_eq!(
148 forecast(&arm("git", &["reset", "--hard"])),
149 DiscardForecast::Suspected {
150 pattern: "git reset --hard".to_owned()
151 }
152 );
153 assert_eq!(
154 forecast(&arm("echo", &["reset", "--hard"])),
155 DiscardForecast::Clear
156 );
157 assert_eq!(forecast(&arm("echo", &["clean"])), DiscardForecast::Clear);
158 }
159
160 #[test]
163 fn find_discards_only_when_told_to_delete() {
164 let arm = |path: &str, args: &[&str]| ExecArm::Program {
165 path: path.into(),
166 args: args.iter().map(|arg| (*arg).to_owned()).collect(),
167 };
168 assert_eq!(
169 [
170 forecast(&arm("find", &[".", "-delete"])),
171 forecast(&arm("find", &[".", "-name", "x"])),
172 forecast(&arm("echo", &["-delete"])),
173 ],
174 [
175 DiscardForecast::Suspected {
176 pattern: "find -delete".to_owned()
177 },
178 DiscardForecast::Clear,
179 DiscardForecast::Clear,
180 ]
181 );
182 }
183
184 #[test]
185 fn program_forecast_reads_path_and_args_whole() {
186 let rm = ExecArm::Program {
187 path: "/usr/bin/rm".into(),
188 args: vec!["-rf".into(), "docs".into()],
189 };
190 assert!(matches!(forecast(&rm), DiscardForecast::Suspected { .. }));
191 let git_ok = ExecArm::Program {
192 path: "git".into(),
193 args: vec!["status".into()],
194 };
195 assert_eq!(forecast(&git_ok), DiscardForecast::Clear);
196 let git_hard = ExecArm::Program {
197 path: "git".into(),
198 args: vec!["reset".into(), "--hard".into()],
199 };
200 assert!(matches!(
201 forecast(&git_hard),
202 DiscardForecast::Suspected { .. }
203 ));
204 let find_delete = ExecArm::Program {
205 path: "find.exe".into(),
206 args: vec![".".into(), "-delete".into()],
207 };
208 assert!(matches!(
209 forecast(&find_delete),
210 DiscardForecast::Suspected { .. }
211 ));
212 }
213
214 #[test]
215 fn text_arms_are_conservative() {
216 let py = ExecArm::Python {
217 code: "import shutil\nshutil.rmtree('build')".into(),
218 };
219 assert!(matches!(forecast(&py), DiscardForecast::Suspected { .. }));
220 let py_write = ExecArm::Python {
221 code: "open('x.txt', 'w').write('hi')".into(),
222 };
223 assert!(matches!(
224 forecast(&py_write),
225 DiscardForecast::Suspected { .. }
226 ));
227 let py_read = ExecArm::Python {
228 code: "print(open('x.txt').read())".into(),
229 };
230 assert_eq!(forecast(&py_read), DiscardForecast::Clear);
231 let sh_trunc = ExecArm::Shell {
232 text: "echo hi > file.txt".into(),
233 };
234 assert!(matches!(
235 forecast(&sh_trunc),
236 DiscardForecast::Suspected { .. }
237 ));
238 let sh_append = ExecArm::Shell {
239 text: "echo hi >> log.txt".into(),
240 };
241 assert_eq!(forecast(&sh_append), DiscardForecast::Clear);
242 }
243
244 proptest! {
245 #[test]
247 fn allow_implies_every_guard_passed(files in 1usize..24, bytes in any::<u64>(),
248 tainted in any::<bool>()) {
249 let paths: Vec<Address> = (0..files).map(|i| addr(&format!("b/f{i}"))).collect();
250 let taint = if tainted {
251 TaintSet::of(TaintSource::new("web:x").unwrap())
252 } else {
253 TaintSet::empty()
254 };
255 let discard = Discard::new(paths, tracked(), taint, ByteLen::new(bytes)).unwrap();
256 let verdict = decide(&DiscardRequest::Planned(discard));
257 if verdict == DiscardVerdict::Allow {
258 prop_assert!(!tainted, "a delete asked for from outside is refused");
259 }
260 }
261 }
262}