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}