git_policy_cli/policy_engine.rs
1//! What:
2//! Run shipped policies in registry order for one lifecycle point and settle the
3//! outcome:
4//! which events were produced and whether the command may proceed.
5//! Why:
6//! Every lifecycle shares one execution contract:
7//! a policy runs only for the triggers
8//! it declares,
9//! at the configured severity,
10//! unless it is filtered out,
11//! escaped or off;
12//! the first error finding stops the pass unless the caller asked to keep going;
13//! and a
14//! policy or lifecycle this executable cannot evaluate is reported as unavailable,
15//! never as clean.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! // const stage = await runPolicyStage({ policies, context, trigger, severities, selectedPolicyIds, escapedPolicyIds, keepGoing });
20//! ```
21
22/// What:
23/// `use` brings names from sibling files into this file;
24/// `super::` means "the parent
25/// module",
26/// where every sibling file of this crate is declared.
27/// Why:
28/// The stage combines configuration,
29/// controls,
30/// the registry and the event types.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// import { PolicyEvent } from './policy_events.ts';
35/// ```
36use super::action::ENGINE_FAILURE_EXIT_CODE;
37use super::config_schema::PolicyConfig;
38use super::diagnostics::EngineFailureCode;
39use super::event_path::EventPath;
40use super::policy_events::{FindingEvent, FindingLocation, PolicyEvent, event_blocks};
41use super::policy_registry::{PolicyId, Severity, policy_descriptor};
42use super::policy_trigger::{Trigger, policy_runs_on, trigger_is_ported};
43use super::wrapper_controls::{Controls, is_escaped};
44
45/// What:
46/// Exit code when a pass ends with an error finding or a fixed-transform rejection.
47/// `i32` is a signed 32-bit integer,
48/// the type of process exit codes.
49/// Why:
50/// Callers branch on 1 for "policy said no" and on 2 for "could not decide".
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// const BLOCKED_EXIT_CODE = 1;
55/// ```
56pub const BLOCKED_EXIT_CODE: i32 = 1;
57
58/// What:
59/// One thing a policy reports.
60/// A `struct` is a record with named fields;
61/// `String` is
62/// owned UTF-8 text;
63/// `Option<T>` is "a value or nothing";
64/// `&'static str` is text
65/// baked into the program.
66/// Why:
67/// A policy describes what it found;
68/// the engine adds the trigger,
69/// the policy name
70/// and the configured severity when it turns the finding into an event.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type PolicyFinding = { code: string; message: string; path?: EventPath; location?: FindingLocation; patch?: PolicyPatch };
75/// ```
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct PolicyFinding {
78 /// The policy-local code,
79 /// lower-case words joined by hyphens.
80 pub code: &'static str,
81 /// The explanation for the person who ran the command.
82 pub message: String,
83 /// The repository path the finding is about,
84 /// when it has one.
85 pub path: Option<EventPath>,
86 /// The byte range the finding is about,
87 /// when it has one.
88 pub location: Option<FindingLocation>,
89 /// Whether the policy proposed a correction for this finding.
90 pub fix_available: bool,
91}
92
93/// What:
94/// What one policy check returned.
95/// An `enum` is a closed set of named alternatives;
96/// `Failed { code, message }` carries named fields.
97/// Why:
98/// "Nothing found",
99/// "cannot be evaluated here" and "could not finish" are three
100/// different answers;
101/// folding the last two into an empty finding list would let an
102/// unchecked command through.
103/// A policy that could not finish names its cause:
104/// `content-unavailable` when candidate bytes or a repository fact could not be
105/// read,
106/// `policy-incomplete` when its own machinery failed.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// type PolicyOutcome = { kind: 'findings'; findings: PolicyFinding[] } | { kind: 'unavailable'; needs: string } | { kind: 'failed'; code: EngineFailureCode; message: string; path?: EventPath };
111/// ```
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum PolicyOutcome {
114 /// The policy was evaluated;
115 /// an empty list means it found nothing.
116 Findings(Vec<PolicyFinding>),
117 /// The policy needs something this executable does not implement yet;
118 /// the text names it.
119 Unavailable(&'static str),
120 /// The policy could not finish.
121 Failed {
122 /// The cause,
123 /// as the engine-failure event reports it.
124 code: EngineFailureCode,
125 /// What failed,
126 /// for the person who ran the command;
127 /// never a candidate's pathname.
128 message: String,
129 /// The candidate the failure is about,
130 /// when one file is responsible;
131 /// the event
132 /// names it in its own field,
133 /// never inside the message.
134 path: Option<EventPath>,
135 },
136}
137
138/// What:
139/// The policies behind the stage.
140/// A `trait` is a named set of methods a type promises
141/// to provide,
142/// like a TS `interface`;
143/// `&mut self` lends the provider for writing,
144/// because a check may cache repository facts.
145/// Why:
146/// The stage owns order,
147/// severity and stopping;
148/// this seam owns what each policy
149/// reads.
150/// Tests drive the stage with a scripted provider,
151/// the executable with real Git.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// interface PolicyChecks { check(policy: PolicyId, trigger: PolicyTrigger): Promise<PolicyOutcome> }
156/// ```
157pub trait PolicyChecks {
158 /// Evaluate one policy for one lifecycle point.
159 fn check(&mut self, policy: PolicyId, trigger: Trigger) -> PolicyOutcome;
160}
161
162/// What:
163/// The settings of one stage.
164/// A `struct` that owns its values:
165/// `PolicyConfig`,
166/// `Controls` and `Vec<PolicyId>` are copies made once per invocation.
167/// Why:
168/// Owning small copies keeps the record free of borrowing rules,
169/// so it can be
170/// handed to every stage of an invocation as one plain value.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// type StageRequest = { trigger: PolicyTrigger; config: PolicyConfig; controls: Controls; selected: PolicyId[] };
175/// ```
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct StageRequest {
178 /// The lifecycle point being checked.
179 pub trigger: Trigger,
180 /// Effective severities.
181 pub config: PolicyConfig,
182 /// Keep-going and per-invocation escapes.
183 pub controls: Controls,
184 /// The `--policy` filter of a direct command;
185 /// empty selects every policy.
186 pub selected: Vec<PolicyId>,
187}
188
189/// What:
190/// Why a pass could not be evaluated by this executable.
191/// Why:
192/// The refusal names either a whole lifecycle or one policy and what it needs.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// type Unavailable = { kind: 'lifecycle'; trigger } | { kind: 'policy'; policy; needs: string };
197/// ```
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum Unavailable {
200 /// The lifecycle of this trigger is not ported.
201 Lifecycle(Trigger),
202 /// This policy needs something that is not ported.
203 Policy {
204 /// The policy that could not be evaluated.
205 policy: PolicyId,
206 /// What it needs,
207 /// in words for the refusal notice.
208 needs: &'static str,
209 },
210}
211
212/// What:
213/// How a stage ended.
214/// Why:
215/// The caller continues only after `Completed`;
216/// each other ending has its own exit code.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// type StageEnd = 'completed' | 'stopped' | 'proposed' | 'failed' | Unavailable;
221/// ```
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub enum StageEnd {
224 /// Every applicable policy was evaluated and none stopped the pass.
225 Completed,
226 /// An error finding stopped the pass.
227 Stopped,
228 /// A policy proposed a correction,
229 /// so the pass ends and restarts on corrected content.
230 Proposed,
231 /// A policy could not finish;
232 /// the last event is the engine failure naming its cause.
233 Failed,
234 /// A policy or the lifecycle cannot be evaluated by this executable.
235 Unavailable(Unavailable),
236}
237
238/// What:
239/// The events of one stage and how it ended.
240/// `Vec<PolicyEvent>` is an owned list.
241/// Why:
242/// Events gathered before a stop are still reported.
243///
244/// In TS you'd write (pseudocode):
245/// ```ts
246/// type StageResult = { events: PolicyEvent[]; end: StageEnd };
247/// ```
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct StageResult {
250 /// Events in emission order.
251 pub events: Vec<PolicyEvent>,
252 /// How the stage ended.
253 pub end: StageEnd,
254}
255
256/// What:
257/// Whether the stage evaluates this policy.
258/// `&StageRequest` borrows the settings.
259/// Why:
260/// The order of the tests is the incumbent's:
261/// trigger,
262/// direct-command filter,
263/// escape,
264/// then severity.
265///
266/// In TS you'd write (pseudocode):
267/// ```ts
268/// const applies = policy.triggers.includes(trigger) && (selected.size === 0 || selected.has(id)) && !escaped.has(id) && severity !== 'off';
269/// ```
270fn policy_applies(request: &StageRequest, policy: PolicyId) -> bool {
271 if !policy_runs_on(policy, request.trigger) {
272 return false;
273 }
274 // `.contains(&policy)` borrows the identity for the lookup.
275 if !request.selected.is_empty() && !request.selected.contains(&policy) {
276 return false;
277 }
278 if is_escaped(&request.controls, policy) {
279 return false;
280 }
281 return request.config.setting(policy).severity != Severity::Off;
282}
283
284/// What:
285/// Whether any of `policies` would be evaluated for this request.
286/// Why:
287/// A lifecycle that only exists for its policies (manual push) is not applicable
288/// when none is enabled,
289/// and the caller must know that before asking the stage.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// const any = policies.some(policy => applies(policy));
294/// ```
295pub fn any_policy_applies(request: &StageRequest, policies: &[PolicyId]) -> bool {
296 // `for policy in policies` borrows each identity; `*policy` copies it out.
297 for policy in policies {
298 if policy_applies(request, *policy) {
299 return true;
300 }
301 }
302 return false;
303}
304
305/// What:
306/// Run `policies` in the given order.
307/// `&mut dyn PolicyChecks` lends "any provider of
308/// checks" for writing (`dyn` means the concrete type is chosen at run time,
309/// like a
310/// TS interface value).
311/// Why:
312/// One policy at a time,
313/// in registry order,
314/// so findings and their numbers are
315/// reproducible.
316/// A trigger whose lifecycle is not ported ends the stage before any
317/// policy runs.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// async function runPolicyStage(request: StageRequest, policies: PolicyId[], checks: PolicyChecks): Promise<StageResult>;
322/// ```
323pub fn run_policy_stage(
324 request: &StageRequest,
325 policies: &[PolicyId],
326 checks: &mut dyn PolicyChecks,
327) -> StageResult {
328 // `Vec::<PolicyEvent>::new()` is an empty owned list; `mut` allows pushing.
329 let mut events: Vec<PolicyEvent> = Vec::<PolicyEvent>::new();
330 if !trigger_is_ported(request.trigger) {
331 return StageResult {
332 events,
333 end: StageEnd::Unavailable(Unavailable::Lifecycle(request.trigger)),
334 };
335 }
336 for policy in policies {
337 if !policy_applies(request, *policy) {
338 continue;
339 }
340 let severity: Severity = request.config.setting(*policy).severity;
341 // `match` unpacks the three outcomes; only `Findings` continues past it.
342 let findings: Vec<PolicyFinding> = match checks.check(*policy, request.trigger) {
343 PolicyOutcome::Findings(found) => found,
344 PolicyOutcome::Unavailable(needs) => {
345 return StageResult {
346 events,
347 end: StageEnd::Unavailable(Unavailable::Policy {
348 policy: *policy,
349 needs,
350 }),
351 };
352 }
353 PolicyOutcome::Failed {
354 code,
355 message,
356 path,
357 } => {
358 events.push(PolicyEvent::EngineFailure {
359 code,
360 message,
361 // `Some(x)` is the "present" case of `Option`.
362 trigger: Some(request.trigger),
363 policy: Some(*policy),
364 path,
365 });
366 return StageResult {
367 events,
368 end: StageEnd::Failed,
369 };
370 }
371 };
372 let found_any: bool = !findings.is_empty();
373 let mut proposed: bool = false;
374 // `for finding in findings` moves each finding out of the list into its event.
375 for finding in findings {
376 // A finding offers a fix exactly when its policy proposed a correction.
377 if finding.fix_available {
378 proposed = true;
379 }
380 events.push(PolicyEvent::Finding(FindingEvent {
381 trigger: request.trigger,
382 policy: *policy,
383 severity,
384 code: finding.code,
385 message: finding.message,
386 path: finding.path,
387 location: finding.location,
388 fix_available: finding.fix_available,
389 }));
390 }
391 // A `warn` that removes the policy's protection is reported even when nothing was found.
392 if severity == Severity::Warn && !policy_descriptor(*policy).warn_safe {
393 events.push(PolicyEvent::WarnUnsafe {
394 trigger: request.trigger,
395 policy: *policy,
396 });
397 }
398 // A proposal ends the stage whatever the severity, as `patchProposed` does in the
399 // installed wrapper's `policy-stage.ts`: later policies read the corrected content.
400 if proposed {
401 return StageResult {
402 events,
403 end: StageEnd::Proposed,
404 };
405 }
406 if severity == Severity::Error && found_any && !request.controls.keep_going {
407 return StageResult {
408 events,
409 end: StageEnd::Stopped,
410 };
411 }
412 }
413 return StageResult {
414 events,
415 end: StageEnd::Completed,
416 };
417}
418
419/// What:
420/// The exit code of a pass that did not reach Git,
421/// from its events and its ending.
422/// `&[PolicyEvent]` borrows the events of every stage of the pass.
423/// Why:
424/// 0 means clean or warnings only,
425/// 1 means an error finding,
426/// a fixed-transform
427/// rejection or a pending correction,
428/// and 2 means the pass could not decide.
429///
430/// In TS you'd write (pseudocode):
431/// ```ts
432/// const exitCode = !complete ? 2 : (hasError || patchProposed) ? 1 : 0;
433/// ```
434pub fn pass_exit_code(events: &[PolicyEvent], end: StageEnd) -> i32 {
435 // `Unavailable(_)` matches that variant whatever it carries; `{}` does nothing and goes on.
436 match end {
437 StageEnd::Failed | StageEnd::Unavailable(_) => return ENGINE_FAILURE_EXIT_CODE,
438 StageEnd::Proposed => return BLOCKED_EXIT_CODE,
439 StageEnd::Completed | StageEnd::Stopped => {}
440 }
441 for event in events {
442 if event_blocks(event) {
443 return BLOCKED_EXIT_CODE;
444 }
445 }
446 return 0;
447}
448
449/// Order,
450/// severity,
451/// stopping and unavailability controls stay out of the release executable.
452#[cfg(test)]
453#[path = "policy_engine_tests.rs"]
454mod tests;