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    #[test]
119    fn program_forecast_reads_path_and_args_whole() {
120        let rm = ExecArm::Program {
121            path: "/usr/bin/rm".into(),
122            args: vec!["-rf".into(), "docs".into()],
123        };
124        assert!(matches!(forecast(&rm), DiscardForecast::Suspected { .. }));
125        let git_ok = ExecArm::Program {
126            path: "git".into(),
127            args: vec!["status".into()],
128        };
129        assert_eq!(forecast(&git_ok), DiscardForecast::Clear);
130        let git_hard = ExecArm::Program {
131            path: "git".into(),
132            args: vec!["reset".into(), "--hard".into()],
133        };
134        assert!(matches!(
135            forecast(&git_hard),
136            DiscardForecast::Suspected { .. }
137        ));
138        let find_delete = ExecArm::Program {
139            path: "find.exe".into(),
140            args: vec![".".into(), "-delete".into()],
141        };
142        assert!(matches!(
143            forecast(&find_delete),
144            DiscardForecast::Suspected { .. }
145        ));
146    }
147
148    #[test]
149    fn text_arms_are_conservative() {
150        let py = ExecArm::Python {
151            code: "import shutil\nshutil.rmtree('build')".into(),
152        };
153        assert!(matches!(forecast(&py), DiscardForecast::Suspected { .. }));
154        let py_write = ExecArm::Python {
155            code: "open('x.txt', 'w').write('hi')".into(),
156        };
157        assert!(matches!(
158            forecast(&py_write),
159            DiscardForecast::Suspected { .. }
160        ));
161        let py_read = ExecArm::Python {
162            code: "print(open('x.txt').read())".into(),
163        };
164        assert_eq!(forecast(&py_read), DiscardForecast::Clear);
165        let sh_trunc = ExecArm::Shell {
166            text: "echo hi > file.txt".into(),
167        };
168        assert!(matches!(
169            forecast(&sh_trunc),
170            DiscardForecast::Suspected { .. }
171        ));
172        let sh_append = ExecArm::Shell {
173            text: "echo hi >> log.txt".into(),
174        };
175        assert_eq!(forecast(&sh_append), DiscardForecast::Clear);
176    }
177
178    proptest! {
179        /// Allow implies planned and clean-handed, at any scale.
180        #[test]
181        fn allow_implies_every_guard_passed(files in 1usize..24, bytes in any::<u64>(),
182                                            tainted in any::<bool>()) {
183            let paths: Vec<Address> = (0..files).map(|i| addr(&format!("b/f{i}"))).collect();
184            let taint = if tainted {
185                TaintSet::of(TaintSource::new("web:x").unwrap())
186            } else {
187                TaintSet::empty()
188            };
189            let discard = Discard::new(paths, tracked(), taint, ByteLen::new(bytes)).unwrap();
190            let verdict = decide(&DiscardRequest::Planned(discard));
191            if verdict == DiscardVerdict::Allow {
192                prop_assert!(!tainted, "a delete asked for from outside is refused");
193            }
194        }
195    }
196}