Skip to main content

kernel/discard/
forecast.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Discard forecasts: reading a command whole before it runs.
7//! The part `crates/kernel/spec/Discard.lean` specifies this module with the
8//! rest of `kernel::discard`.
9
10use crate::tool::ExecArm;
11
12/// Suspicious-command snippets for the text arms (data face, pub(crate)).
13/// Text prediction is obfuscatable by design — hits route conservatively,
14/// and the git checkpoint net is the honest backstop.
15pub(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/// Deliberately exhaustive forecast.
21#[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
32/// The pre-judgment: Program reads `(path, args)` whole — a
33/// whitelisted name with poisoned args is no trust at all; Python and
34/// Shell get substring conservatism.
35pub 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    /// A planned discard reads back the paths, the plan, the provenance and
119    /// the size it was made with, which is what the door and the ledger
120    /// line are built from.
121    #[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    /// Each of git's two discarding commands is suspected on its own, and
134    /// the same words given to another program are not git's.
135    #[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    /// `find` discards only when told `-delete`, and `-delete` handed to
161    /// another program is that program's word.
162    #[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        /// Allow implies planned and clean-handed, at any scale.
246        #[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}