Skip to main content

codehelion_core/
execution.rs

1//! What a run is allowed to run, and what it reads instead.
2//!
3//! Semantic analysis wants things only a build can produce: a table a build
4//! script writes, the items a derive macro expands to, the flags a configure
5//! step decides. Producing them means running code out of the project being
6//! audited, which is the one thing a tool pointed at somebody else's repository
7//! must not do by accident.
8//!
9//! So execution is not a mode this tool has and can be talked into leaving. The
10//! default is that no class of execution is permitted, permission is granted a
11//! class at a time, and everything refused is reported: what was skipped, what
12//! it cost, and the exact thing to type to allow it. A skip nobody is told
13//! about reads as an answer.
14//!
15//! # Why per class
16//!
17//! One switch for "run things" collapses decisions of very different weight.
18//! Expanding a procedural macro runs a compiler plugin the project already
19//! trusts its own developers with; running a configure step runs a shell
20//! script that may reach the network. Somebody willing to do the first is not
21//! thereby willing to do the second, and a single flag would make agreeing to
22//! either mean agreeing to both.
23//!
24//! # What is always allowed
25//!
26//! Reading what the project already has: manifests, a compilation database,
27//! artifacts a build left behind, debug information. None of them run anything,
28//! and they are listed explicitly ([`Reading`]) rather than left as "whatever
29//! is not execution", so that a new information source has to be classified
30//! before it can be used.
31
32use std::collections::BTreeSet;
33use std::time::Duration;
34
35/// Something that would run code supplied by the project being audited.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
37pub enum Execution {
38    /// A Cargo build script.
39    BuildScript,
40    /// A procedural macro, expanded by compiling and calling it.
41    ProceduralMacro,
42    /// A configure step: `CMake`, autotools, or a generator script.
43    Configure,
44    /// A compiler wrapper the project interposes.
45    CompilerWrapper,
46    /// A command that generates source files.
47    GeneratedSource,
48}
49
50/// Every class, in a fixed order.
51///
52/// Kept as a list rather than derived, so adding a class is a decision that has
53/// to be made once here and then shows up everywhere it matters — including in
54/// the test that checks a permission for one class grants nothing else.
55pub const EXECUTION_CLASSES: [Execution; 5] = [
56    Execution::BuildScript,
57    Execution::ProceduralMacro,
58    Execution::Configure,
59    Execution::CompilerWrapper,
60    Execution::GeneratedSource,
61];
62
63impl Execution {
64    /// Stable lowercase identifier: what a person types to permit it, and what
65    /// a report prints when it was refused.
66    #[must_use]
67    pub const fn name(self) -> &'static str {
68        match self {
69            Self::BuildScript => "build-script",
70            Self::ProceduralMacro => "proc-macro",
71            Self::Configure => "configure",
72            Self::CompilerWrapper => "compiler-wrapper",
73            Self::GeneratedSource => "generated-source",
74        }
75    }
76
77    /// The class a name refers to, or `None` for a name this build has never
78    /// heard of — which is refused rather than ignored, since silently
79    /// dropping an unrecognised permission would leave somebody believing they
80    /// had granted one.
81    #[must_use]
82    pub fn from_name(name: &str) -> Option<Self> {
83        EXECUTION_CLASSES
84            .into_iter()
85            .find(|class| class.name() == name)
86    }
87
88    /// What is lost by refusing it, in the words a report uses.
89    #[must_use]
90    pub const fn cost(self) -> &'static str {
91        match self {
92            Self::BuildScript => {
93                "types and items that only exist after a build script has generated them"
94            }
95            Self::ProceduralMacro => "the items a derive or attribute macro expands to",
96            Self::Configure => "the compile flags a configure step would have decided",
97            Self::CompilerWrapper => "whatever the project's own compiler wrapper adds",
98            Self::GeneratedSource => "source files that a command produces rather than a person",
99        }
100    }
101
102    /// The argument that permits this class.
103    #[must_use]
104    pub fn permission_argument(self) -> String {
105        format!("--allow-execution={}", self.name())
106    }
107}
108
109/// Something a run may read without running anything.
110///
111/// Listed rather than assumed: a source of information that is not on this list
112/// has not been thought about yet, and the way to add one is to decide which
113/// side of the line it falls on.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
115pub enum Reading {
116    /// Source files.
117    Source,
118    /// Cargo manifests and the metadata derived from them without a build.
119    CargoMetadata,
120    /// A `compile_commands.json` that already exists.
121    CompilationDatabase,
122    /// Artifacts a previous build left behind.
123    ExistingArtifacts,
124    /// Debug information inside those artifacts.
125    DebugInformation,
126}
127
128/// What a run is permitted to run.
129#[derive(Debug, Clone, PartialEq, Eq, Default)]
130pub struct ExecutionPolicy {
131    allowed: BTreeSet<Execution>,
132}
133
134/// Why something was not done, and how to change that.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Refusal {
137    /// The class that was refused.
138    pub execution: Execution,
139    /// What the refusal cost.
140    pub cost: &'static str,
141    /// The argument that would permit it.
142    pub permission_argument: String,
143}
144
145impl Refusal {
146    /// A single line for a report or a log.
147    #[must_use]
148    pub fn describe(&self) -> String {
149        format!(
150            "skipped {}: not permitted, so this run has no {}. Pass {} to allow it.",
151            self.execution.name(),
152            self.cost,
153            self.permission_argument
154        )
155    }
156}
157
158impl ExecutionPolicy {
159    /// The default: nothing may run.
160    #[must_use]
161    pub fn deny_all() -> Self {
162        Self::default()
163    }
164
165    /// The same policy with one more class permitted.
166    #[must_use]
167    pub fn allowing(mut self, execution: Execution) -> Self {
168        self.allowed.insert(execution);
169        self
170    }
171
172    /// The policy described by a comma-separated list of class names.
173    ///
174    /// # Errors
175    ///
176    /// Returns the first name that is not a class, so that a typo in a
177    /// permission is refused rather than quietly granting nothing.
178    pub fn parse(names: &str) -> Result<Self, UnknownExecution> {
179        let mut policy = Self::deny_all();
180        for name in names.split(',').map(str::trim).filter(|n| !n.is_empty()) {
181            let execution = Execution::from_name(name).ok_or_else(|| UnknownExecution {
182                name: name.to_string(),
183            })?;
184            policy = policy.allowing(execution);
185        }
186        Ok(policy)
187    }
188
189    /// Whether this class may run.
190    #[must_use]
191    pub fn permits(&self, execution: Execution) -> bool {
192        self.allowed.contains(&execution)
193    }
194
195    /// Reading never needs permission; the method exists so that the two sides
196    /// of the line are asked the same way and a caller cannot reach a source
197    /// this build has not classified.
198    #[must_use]
199    pub const fn permits_reading(&self, _reading: Reading) -> bool {
200        true
201    }
202
203    /// What refusing this class means, or `None` if it is permitted.
204    #[must_use]
205    pub fn refusal(&self, execution: Execution) -> Option<Refusal> {
206        if self.permits(execution) {
207            return None;
208        }
209        Some(Refusal {
210            execution,
211            cost: execution.cost(),
212            permission_argument: execution.permission_argument(),
213        })
214    }
215
216    /// The classes permitted, in a fixed order.
217    #[must_use]
218    pub fn permitted(&self) -> Vec<Execution> {
219        self.allowed.iter().copied().collect()
220    }
221}
222
223/// A permission naming a class that does not exist.
224#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
225#[error(
226    "no execution class is called `{name}`; the classes are \
227     build-script, proc-macro, configure, compiler-wrapper, generated-source"
228)]
229pub struct UnknownExecution {
230    /// The name that was given.
231    pub name: String,
232}
233
234/// The ceilings a run works under.
235///
236/// Separate from the execution policy because they answer a different question:
237/// the policy says what may run, and these say how much a run may spend on
238/// input that turns out to be hostile rather than merely large.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct Limits {
241    /// Largest file read.
242    pub max_file_bytes: u64,
243    /// Longest a single file may be parsed for.
244    pub parse_timeout: Duration,
245    /// Largest amount of memory a subprocess may use, where the platform can
246    /// say so.
247    pub max_subprocess_bytes: Option<u64>,
248    /// Largest number of candidate pairs generated before a run gives up on
249    /// generating more and says so.
250    pub max_candidates: usize,
251    /// Longest posting list or fragment class admitted to candidate pairing.
252    ///
253    /// This bounds the fan-out before the pair budget applies. Keeping it
254    /// separate makes the untrusted profile cap both the number of lists and
255    /// the work one high-frequency list can create.
256    pub posting_cap: usize,
257    /// Largest related component refined as one group.
258    ///
259    /// Complete-linkage refinement can repeatedly compare a component, so a
260    /// distinct ceiling keeps an adversarially large related set bounded even
261    /// after candidate generation has stopped.
262    pub max_component: usize,
263    /// Largest distinct Structural pairs admitted to precise verification.
264    pub verification_budget: usize,
265    /// Largest dynamic-programming cell count for one Structural alignment.
266    pub max_alignment_cells: usize,
267    /// Longest a compiler helper may spend answering for one source unit.
268    pub helper_timeout: Duration,
269    /// What may run.
270    pub execution: ExecutionPolicy,
271}
272
273impl Default for Limits {
274    fn default() -> Self {
275        Self {
276            max_file_bytes: crate::discovery::DEFAULT_MAX_FILE_BYTES,
277            parse_timeout: Duration::from_secs(30),
278            max_subprocess_bytes: None,
279            max_candidates: 5_000_000,
280            // The structural candidate pipeline's default is the largest
281            // shipped posting ceiling. The untrusted profile below is lower
282            // than both it and the Fast pipeline's smaller default.
283            posting_cap: 256,
284            max_component: 1024,
285            verification_budget: 1_000_000,
286            max_alignment_cells: 4_000_000,
287            helper_timeout: Duration::from_secs(300),
288            execution: ExecutionPolicy::deny_all(),
289        }
290    }
291}
292
293impl Limits {
294    /// The profile for a repository nobody vouches for.
295    ///
296    /// Every ceiling lower than the default and nothing permitted to run. It is
297    /// a starting point rather than a sandbox: it bounds what a hostile input
298    /// can cost, and it cannot bound what a program does once something has
299    /// agreed to run it, which is why it grants no execution at all.
300    #[must_use]
301    pub fn untrusted() -> Self {
302        Self {
303            max_file_bytes: 512 * 1024,
304            parse_timeout: Duration::from_secs(5),
305            max_subprocess_bytes: Some(1024 * 1024 * 1024),
306            max_candidates: 500_000,
307            // 32 is below Fast's 64 and Structural's 256 default caps, so it
308            // constrains every shipped pairing path rather than only one.
309            posting_cap: 32,
310            // Refinement has super-linear worst-case cost. 128 keeps the
311            // contained piece small enough for the profile while preserving a
312            // useful amount of context for ordinary duplicate families.
313            max_component: 128,
314            verification_budget: 100_000,
315            max_alignment_cells: 250_000,
316            // Compiler helpers may legitimately take longer than a lexer,
317            // but five minutes makes a stalled helper an unbounded wait for an
318            // untrusted tree. Thirty seconds is deliberately conservative.
319            helper_timeout: Duration::from_secs(30),
320            execution: ExecutionPolicy::deny_all(),
321        }
322    }
323
324    /// Whether every ceiling here is at or below `other`'s.
325    ///
326    /// A profile that claims to be stricter has to be stricter in every
327    /// dimension; one that tightened a timeout while raising a size ceiling
328    /// would be a different trade, not a stricter one.
329    #[must_use]
330    pub fn is_at_most(&self, other: &Self) -> bool {
331        self.max_file_bytes <= other.max_file_bytes
332            && self.parse_timeout <= other.parse_timeout
333            && self.max_candidates <= other.max_candidates
334            && self.posting_cap <= other.posting_cap
335            && self.verification_budget <= other.verification_budget
336            && self.max_alignment_cells <= other.max_alignment_cells
337            && self.max_component <= other.max_component
338            && self.helper_timeout <= other.helper_timeout
339            && option_ceiling_at_most(self.max_subprocess_bytes, other.max_subprocess_bytes)
340            && self
341                .execution
342                .permitted()
343                .iter()
344                .all(|class| other.execution.permits(*class))
345    }
346}
347
348/// Whether an optional memory ceiling is no weaker than another one.
349///
350/// `None` means no ceiling, so a bounded profile is at most an unbounded one,
351/// while an unbounded profile is never at most a bounded one.
352const fn option_ceiling_at_most(left: Option<u64>, right: Option<u64>) -> bool {
353    match (left, right) {
354        (_, None) => true,
355        (Some(left), Some(right)) => left <= right,
356        (None, Some(_)) => false,
357    }
358}
359
360#[cfg(test)]
361#[allow(clippy::unwrap_used, clippy::expect_used)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn nothing_runs_unless_it_was_asked_for() {
367        let policy = ExecutionPolicy::deny_all();
368        for class in EXECUTION_CLASSES {
369            assert!(!policy.permits(class), "{class:?}");
370            assert!(policy.refusal(class).is_some(), "{class:?}");
371        }
372        assert_eq!(ExecutionPolicy::default(), policy);
373    }
374
375    /// The whole point of classifying: agreeing to expand a macro is not
376    /// agreeing to run a configure script.
377    #[test]
378    fn permitting_one_class_permits_only_that_class() {
379        let policy = ExecutionPolicy::deny_all().allowing(Execution::ProceduralMacro);
380        assert!(policy.permits(Execution::ProceduralMacro));
381        for class in EXECUTION_CLASSES {
382            if class != Execution::ProceduralMacro {
383                assert!(!policy.permits(class), "{class:?}");
384            }
385        }
386    }
387
388    /// The advice in a refusal has to work. Writing the argument as prose
389    /// beside the code that parses it leaves the two free to drift, and the
390    /// drift shows up as a message telling somebody to type something that does
391    /// nothing.
392    #[test]
393    fn the_argument_a_refusal_names_is_the_argument_that_permits_it() {
394        for class in EXECUTION_CLASSES {
395            let refusal = ExecutionPolicy::deny_all().refusal(class).unwrap();
396            let value = refusal
397                .permission_argument
398                .split_once('=')
399                .map(|(_, value)| value)
400                .unwrap();
401            let policy = ExecutionPolicy::parse(value).unwrap();
402            assert!(policy.permits(class), "{class:?}: {refusal:?}");
403            assert!(refusal.describe().contains(class.name()));
404        }
405    }
406
407    #[test]
408    fn several_permissions_can_be_given_at_once() {
409        let policy = ExecutionPolicy::parse("build-script, proc-macro").unwrap();
410        assert_eq!(
411            policy.permitted(),
412            vec![Execution::BuildScript, Execution::ProceduralMacro]
413        );
414    }
415
416    /// A misspelled permission grants nothing, and somebody who misspelled one
417    /// believes they granted something. Refusing is the only outcome that does
418    /// not mislead.
419    #[test]
420    fn a_permission_nobody_can_grant_is_an_error_rather_than_a_shrug() {
421        let error = ExecutionPolicy::parse("build-scripts").unwrap_err();
422        assert_eq!(error.name, "build-scripts");
423        assert!(error.to_string().contains("build-script"));
424    }
425
426    #[test]
427    fn every_class_has_a_name_that_maps_back() {
428        for class in EXECUTION_CLASSES {
429            assert_eq!(Execution::from_name(class.name()), Some(class), "{class:?}");
430        }
431        assert_eq!(Execution::from_name("run-everything"), None);
432    }
433
434    #[test]
435    fn reading_what_the_project_already_has_needs_no_permission() {
436        let policy = ExecutionPolicy::deny_all();
437        for reading in [
438            Reading::Source,
439            Reading::CargoMetadata,
440            Reading::CompilationDatabase,
441            Reading::ExistingArtifacts,
442            Reading::DebugInformation,
443        ] {
444            assert!(policy.permits_reading(reading), "{reading:?}");
445        }
446    }
447
448    #[test]
449    fn the_untrusted_profile_is_stricter_in_every_dimension() {
450        let untrusted = Limits::untrusted();
451        let default = Limits::default();
452        assert!(untrusted.is_at_most(&default));
453        assert!(!default.is_at_most(&untrusted));
454        for class in EXECUTION_CLASSES {
455            assert!(!untrusted.execution.permits(class), "{class:?}");
456        }
457    }
458
459    /// A profile is only stricter if it is stricter everywhere; the comparison
460    /// has to notice a trade rather than call it an improvement.
461    #[test]
462    fn a_profile_that_trades_one_ceiling_for_another_is_not_stricter() {
463        let traded = Limits {
464            max_file_bytes: u64::MAX,
465            ..Limits::untrusted()
466        };
467        assert!(!traded.is_at_most(&Limits::default()));
468    }
469}