Skip to main content

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;