Skip to main content

git_policy_cli/policy/
policy_events.rs

1//! What:
2//!  The events one policy pass produces,
3//!  and their JSON Lines rendering.
4//! Why:
5//!  Agents and scripts read these lines from wrapper standard error and from
6//!      `git cli-git check|fix` standard output.
7//!  Each event is one compact JSON object with
8//!      the incumbent's field names and field order,
9//!  so existing readers parse native
10//!      events unchanged.
11//!  Events are numbered when rendered,
12//!  so a list of events always
13//!      prints with consecutive numbers in emission order.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! // process.stderr.write(renderPolicyEvents(result.events));
18//! ```
19
20/// What:
21///  `use` brings names from sibling files into this file;
22///  `super::` means "the parent
23///       module",
24///  where every sibling file of this crate is declared.
25/// Why:
26///   JSON string escaping,
27///  the failure-code spellings and the schema version already
28///       live in `diagnostics.rs`;
29///  policy and severity names live in the registry.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// import { jsonString, SCHEMA_VERSION } from './diagnostics.ts';
34/// ```
35use crate::diagnostics::{
36    EngineFailureCode, SCHEMA_VERSION, engine_failure_code_name, json_string,
37};
38/// A pathname as events report it,
39///  and the encoding of its exact bytes.
40use crate::event_hints::{
41    POST_COMMIT_BLOCKED_HINT, WARN_UNSAFE_HINT, engine_failure_hint, finding_hint,
42    push_hint_field,
43};
44use crate::event_path::{EventPath, base64_standard};
45use crate::policy_registry::{PolicyId, Severity, policy_descriptor, severity_name};
46use crate::policy_trigger::{Trigger, trigger_name};
47
48/// The code of the warning for a `warn` severity on a policy whose protection `warn` removes.
49pub const WARN_UNSAFE_CODE: &str = "warn-unsafe";
50
51/// What:
52///  A byte range inside a candidate.
53///  A `struct` is a record with named fields;
54///  `u64`
55///       is an unsigned 64-bit integer (siblings `u32`,
56///  `usize`).
57/// Why:
58///   Content policies point at the exact bytes of a finding.
59///  `u64` holds any file
60///       offset on every platform,
61///  where `usize` would be 32 bits on a 32-bit system.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// type FindingLocation = { byteStart: number; byteEnd: number };
66/// ```
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub struct FindingLocation {
69    /// Offset of the first byte.
70    pub byte_start: u64,
71    /// Offset one past the last byte.
72    pub byte_end: u64,
73}
74
75/// What:
76///  One policy finding as it is reported.
77///  `String` is owned UTF-8 text (sibling `&str`
78///       borrows);
79///  `Option<T>` is "a value or nothing";
80///  `&'static str` is text baked into
81///       the program.
82/// Why:
83///   The record owns its message and path because events outlive the check that made
84///       them.
85///  The code is compiled-in text,
86///  so a shipped policy cannot emit an empty or
87///       misspelled code at run time.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type FindingEvent = { trigger; policyId; severity: 'warn' | 'error'; code; message; path?; pathBytes?; location?; fix: 'none' | 'available' };
92/// ```
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct FindingEvent {
95    /// The lifecycle point that ran the policy.
96    pub trigger: Trigger,
97    /// The policy that reported.
98    pub policy: PolicyId,
99    /// The effective severity,
100    ///  `Warn` or `Error`.
101    pub severity: Severity,
102    /// The policy-local code;
103    ///  rendering prefixes the policy name.
104    pub code: &'static str,
105    /// The explanation for the person who ran the command.
106    pub message: String,
107    /// The repository path the finding is about,
108    ///  when it has one.
109    pub path: Option<EventPath>,
110    /// The byte range the finding is about,
111    ///  when it has one.
112    pub location: Option<FindingLocation>,
113    /// Whether the engine holds a correction for this finding.
114    pub fix_available: bool,
115}
116
117/// What:
118///  Which hint follows an engine failure's message.
119/// Why:
120///   The code's hint is a guess that holds for most failures of that code; a failure that knows its cause chooses its own
121///  hint, or none, so the hint is never one that is untrue for the case.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// type EngineFailureHint = { kind: 'of-code' } | { kind: 'chosen'; hint?: string };
126/// ```
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum EngineFailureHint {
129    /// The hint of the failure's code.
130    OfCode,
131    /// The failure's own hint, or none.
132    Chosen(Option<&'static str>),
133}
134
135/// What:
136///  Every event a policy pass can produce.
137///  An `enum` is a closed set of named
138///       alternatives;
139///  each variant carries its own fields.
140///  `Vec<EventPath>` is an owned
141///       list of pathnames in their event form.
142/// Why:
143///   One list of events is rendered in order,
144///  whatever mixture of kinds it holds.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// type PolicyEvent = FindingEvent | ConfigurationWarningEvent | CoreFindingEvent | EngineFailureEvent | FixSummaryEvent | CommitLandedEvent | WordMergedEvent;
149/// ```
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum PolicyEvent {
152    /// A policy reported something.
153    Finding(FindingEvent),
154    /// A policy that `warn` does not protect is configured as `warn`.
155    WarnUnsafe {
156        /// The lifecycle point that ran the policy.
157        trigger: Trigger,
158        /// The policy configured as `warn`.
159        policy: PolicyId,
160    },
161    /// A fixed transform rejected the command;
162    ///  it has no severity and cannot be configured.
163    CoreFinding {
164        /// The transform,
165        ///  for example `commit-only`.
166        core_id: &'static str,
167        /// The transform-local code;
168        ///  rendering prefixes the transform name.
169        code: &'static str,
170        /// The explanation for the person who ran the command.
171        message: String,
172    },
173    /// The pass could not complete.
174    EngineFailure {
175        /// The stable cause.
176        code: EngineFailureCode,
177        /// Which hint follows the message.
178        hint: EngineFailureHint,
179        /// The explanation for the person who ran the command.
180        message: String,
181        /// The lifecycle point,
182        ///  when the failure belongs to one.
183        trigger: Option<Trigger>,
184        /// The policy that could not complete,
185        ///  when one is responsible.
186        policy: Option<PolicyId>,
187        /// The repository path responsible,
188        ///  when the failure is about one path.
189        path: Option<EventPath>,
190    },
191    /// Corrections were applied.
192    FixSummary {
193        /// The fixing lifecycle point.
194        trigger: Trigger,
195        /// How many passes changed candidate content before it settled.
196        passes: u64,
197        /// The changed paths,
198        ///  each once,
199        ///  in Git's byte order.
200        changed_paths: Vec<EventPath>,
201    },
202    /// The landed commit whose automatic backup the post-commit gate stopped.
203    ///  The commit stays local; the event names it so the user can push it deliberately.
204    CommitLanded {
205        /// The exact landed commit object ID.
206        oid: String,
207    },
208    /// A commit replayed over a branch that moved had files merged by word where Git's line merge reported a conflict.
209    ///  The event lists each merged part, so the person sees what was combined.
210    WordMerged {
211        /// One line per merged part, each naming the file and the part.
212        lines: Vec<String>,
213    },
214}
215
216/// What:
217///  Whether an event blocks the command.
218///  `&PolicyEvent` borrows the event read-only.
219/// Why:
220///   An error finding and a fixed-transform rejection keep Git from running;
221///  a
222///       warning finding and a configuration warning do not.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// const blocks = event.type === 'core-finding' || (event.type === 'finding' && event.severity === 'error');
227/// ```
228pub fn event_blocks(event: &PolicyEvent) -> bool {
229    // `match` picks one arm per variant; `{ .. }` ignores the fields a variant carries.
230    match event {
231        PolicyEvent::Finding(finding) => return finding.severity == Severity::Error,
232        PolicyEvent::CoreFinding { .. } => return true,
233        PolicyEvent::WarnUnsafe { .. }
234        | PolicyEvent::EngineFailure { .. }
235        | PolicyEvent::FixSummary { .. }
236        | PolicyEvent::CommitLanded { .. }
237        | PolicyEvent::WordMerged { .. } => return false,
238    }
239}
240
241/// What:
242///  The opening every event shares:
243///  schema version,
244///  sequence and type.
245/// Why:
246///   Readers branch on these three fields first,
247///  and they always come first.
248///
249/// In TS you'd write (pseudocode):
250/// ```ts
251/// const head = `{"schemaVersion":1,"sequence":${sequence},"type":${JSON.stringify(type)}`;
252/// ```
253fn event_head(sequence: u64, event_type: &str) -> String {
254    // `format!` builds owned text; `{{` is a literal brace.
255    return format!(
256        "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":{}",
257        json_string(event_type)
258    );
259}
260
261/// What:
262///  Append `,"name":"value"` to a line.
263///  `&mut String` lends the line for appending.
264/// Why:
265///   Every text field is encoded at this final step,
266///  so no value can end its JSON
267///       string or its line.
268///
269/// In TS you'd write (pseudocode):
270/// ```ts
271/// line += `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
272/// ```
273fn push_text_field(line: &mut String, name: &str, value: &str) {
274    // `.as_str()` lends the owned text to `push_str`.
275    line.push_str(format!(",\"{name}\":{}", json_string(value)).as_str());
276}
277
278/// What:
279///  Append `,"path":"..."` and,
280///  for a name that is not UTF-8,
281///  `,"pathBytes":"..."`.
282///       `&EventPath` borrows the pathname's event form.
283/// Why:
284///   `path` keeps its meaning for every existing reader;
285///  `pathBytes` is the optional
286///       field schema version 1 allows (`SPEC.md`,
287///  "Unknown fields may be added only in a
288///       backward-compatible schema revision"),
289///  present only when `path` is not exact.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// line.path = path.text; if (path.exact) line.pathBytes = base64(path.exact);
294/// ```
295fn push_path_field(line: &mut String, path: &EventPath) {
296    push_text_field(line, "path", path.text());
297    // `if let Some(exact) = ...` runs only for a name that kept its bytes.
298    if let Some(exact) = path.exact() {
299        push_text_field(line, "pathBytes", base64_standard(exact).as_str());
300    }
301}
302
303/// What:
304///  Render a finding's fields after the shared opening.
305/// Why:
306///   The order is the incumbent's:
307///  trigger,
308///  policy,
309///  severity,
310///  code,
311///  message,
312///  then the
313///       optional path (with its exact bytes) and location,
314///  then the fix state.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// JSON.stringify({ trigger, policyId, severity, code: `${policyId}/${code}`, message, path, location, fix })
319/// ```
320fn push_finding(line: &mut String, finding: &FindingEvent) {
321    let policy_name: &str = policy_descriptor(finding.policy).name;
322    push_text_field(line, "trigger", trigger_name(finding.trigger));
323    push_text_field(line, "policyId", policy_name);
324    push_text_field(line, "severity", severity_name(finding.severity));
325    push_text_field(
326        line,
327        "code",
328        format!("{policy_name}/{}", finding.code).as_str(),
329    );
330    push_text_field(line, "message", finding.message.as_str());
331    push_hint_field(line, finding_hint(policy_name, finding.code, finding.fix_available));
332    // `if let Some(path) = &finding.path` borrows the path only when one is present.
333    if let Some(path) = &finding.path {
334        push_path_field(line, path);
335    }
336    if let Some(location) = finding.location {
337        line.push_str(
338            format!(
339                ",\"location\":{{\"byteStart\":{},\"byteEnd\":{}}}",
340                location.byte_start, location.byte_end
341            )
342            .as_str(),
343        );
344    }
345    let fix: &str = if finding.fix_available {
346        "available"
347    } else {
348        "none"
349    };
350    push_text_field(line, "fix", fix);
351}
352
353/// What:
354///  Render one JSON string per changed path:
355///  its text,
356///  or the base64 of its bytes.
357///       `fn(&EventPath) -> String` is a plain function pointer choosing what each entry holds.
358/// Why:
359///   A fix summary lists every path it changed,
360///  and its byte list lines up with that
361///       list entry for entry.
362///
363/// In TS you'd write (pseudocode):
364/// ```ts
365/// JSON.stringify(changedPaths.map(entry))
366/// ```
367fn path_array(paths: &[EventPath], entry: fn(&EventPath) -> String) -> String {
368    // `String::from` copies the borrowed text into an owned, growable line.
369    let mut array: String = String::from("[");
370    // `.iter().enumerate()` yields `(index, path)` pairs in order.
371    for (index, path) in paths.iter().enumerate() {
372        if index > 0 {
373            array.push(',');
374        }
375        array.push_str(json_string(entry(path).as_str()).as_str());
376    }
377    array.push(']');
378    return array;
379}
380
381/// What:
382///  The readable text of one changed path,
383///  as a `changedPaths` entry.
384/// Why:
385///   A named function for `path_array`,
386///  because the repository bans anonymous functions.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// const pathText = (path: EventPath) => path.text;
391/// ```
392fn path_text(path: &EventPath) -> String {
393    return String::from(path.text());
394}
395
396/// What:
397///  The base64 of one changed path's bytes,
398///  as a `changedPathBytes` entry.
399/// Why:
400///   Names that are UTF-8 are listed too,
401///  so index `n` of both lists is the same file.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// const pathBytes = (path: EventPath) => base64(path.bytes);
406/// ```
407fn path_bytes(path: &EventPath) -> String {
408    return base64_standard(path.bytes());
409}
410
411/// What:
412///  Append a list of paths as `,"<text_key>":[...]` and,
413///  when any of them is not UTF-8,
414///       `,"<bytes_key>":[...]` with one base64 entry per path.
415/// Why:
416///   An array cannot hold an absent entry without JSON `null`,
417///  which events never
418///       carry,
419///  so the byte list names every path once it is needed at all,
420///  and is
421///       absent while every name is exact.
422///
423/// In TS you'd write (pseudocode):
424/// ```ts
425/// line[textKey] = paths.map(p => p.text); if (paths.some(p => p.exact)) line[bytesKey] = paths.map(p => base64(p.bytes));
426/// ```
427fn push_path_list(line: &mut String, text_key: &str, bytes_key: &str, paths: &[EventPath]) {
428    line.push_str(format!(",\"{text_key}\":{}", path_array(paths, path_text)).as_str());
429    for path in paths {
430        if path.exact().is_some() {
431            line.push_str(format!(",\"{bytes_key}\":{}", path_array(paths, path_bytes)).as_str());
432            return;
433        }
434    }
435}
436
437/// What:
438///  The wire spelling of an event's kind.
439/// Why:
440///   The `type` field is the third field of every line and decides how a reader
441///       interprets the rest.
442///
443/// In TS you'd write (pseudocode):
444/// ```ts
445/// const type = event.type;
446/// ```
447fn event_type(event: &PolicyEvent) -> &'static str {
448    match event {
449        PolicyEvent::Finding(_) => return "finding",
450        PolicyEvent::WarnUnsafe { .. } => return "configuration-warning",
451        PolicyEvent::CoreFinding { .. } => return "core-finding",
452        PolicyEvent::EngineFailure { .. } => return "engine-failure",
453        PolicyEvent::FixSummary { .. } => return "fix-summary",
454        PolicyEvent::CommitLanded { .. } => return "commit-landed",
455        PolicyEvent::WordMerged { .. } => return "word-merged",
456    }
457}
458
459/// What:
460///  Render one event as a line-terminated JSON object.
461///  `&PolicyEvent` borrows the
462///       event;
463///  the result is owned text.
464/// Why:
465///   One function owns every field order,
466///  so two events of one kind can never differ
467///       in shape.
468///
469/// In TS you'd write (pseudocode):
470/// ```ts
471/// function renderPolicyEvent(sequence: number, event: PolicyEvent): string { return JSON.stringify({ ...event, sequence }) + '\n'; }
472/// ```
473pub fn render_policy_event(sequence: u64, event: &PolicyEvent) -> String {
474    // `mut` allows appending the fields of the matched variant.
475    let mut line: String = event_head(sequence, event_type(event));
476    match event {
477        // `&mut line` lends the line for appending.
478        PolicyEvent::Finding(finding) => push_finding(&mut line, finding),
479        PolicyEvent::WarnUnsafe { trigger, policy } => {
480            let policy_name: &str = policy_descriptor(*policy).name;
481            // `*trigger` copies the small value out of the borrowed event.
482            push_text_field(&mut line, "trigger", trigger_name(*trigger));
483            push_text_field(&mut line, "policyId", policy_name);
484            push_text_field(&mut line, "code", WARN_UNSAFE_CODE);
485            push_text_field(
486                &mut line,
487                "message",
488                format!("Policy {policy_name} is warn-unsafe but configured as warn.").as_str(),
489            );
490            push_hint_field(&mut line, Some(WARN_UNSAFE_HINT));
491        }
492        PolicyEvent::CoreFinding {
493            core_id,
494            code,
495            message,
496        } => {
497            push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
498            push_text_field(&mut line, "coreId", core_id);
499            push_text_field(&mut line, "code", format!("{core_id}/{code}").as_str());
500            push_text_field(&mut line, "message", message.as_str());
501        }
502        PolicyEvent::EngineFailure {
503            code,
504            hint,
505            message,
506            trigger,
507            policy,
508            path,
509        } => {
510            let code_name: &str = engine_failure_code_name(*code);
511            push_text_field(&mut line, "code", code_name);
512            push_text_field(&mut line, "message", message.as_str());
513            push_hint_field(
514                &mut line,
515                match hint {
516                    EngineFailureHint::OfCode => engine_failure_hint(code_name),
517                    EngineFailureHint::Chosen(chosen) => *chosen,
518                },
519            );
520            if let Some(found) = trigger {
521                push_text_field(&mut line, "trigger", trigger_name(*found));
522            }
523            if let Some(found) = policy {
524                push_text_field(&mut line, "policyId", policy_descriptor(*found).name);
525            }
526            if let Some(found) = path {
527                push_path_field(&mut line, found);
528            }
529        }
530        PolicyEvent::FixSummary {
531            trigger,
532            passes,
533            changed_paths,
534        } => {
535            push_text_field(&mut line, "trigger", trigger_name(*trigger));
536            line.push_str(format!(",\"passes\":{passes}").as_str());
537            push_path_list(&mut line, "changedPaths", "changedPathBytes", changed_paths.as_slice());
538        }
539        PolicyEvent::CommitLanded { oid } => {
540            push_text_field(&mut line, "oid", oid);
541            push_text_field(&mut line, "outcome", "post-commit-blocked");
542            push_text_field(
543                &mut line,
544                "message",
545                format!("Commit {oid} remains local; post-commit gate blocked automatic backup.")
546                    .as_str(),
547            );
548            push_hint_field(&mut line, Some(POST_COMMIT_BLOCKED_HINT));
549        }
550        PolicyEvent::WordMerged { lines } => {
551            push_text_field(&mut line, "outcome", "word-merged");
552            push_text_field(&mut line, "message", lines.join("\n").as_str());
553        }
554    }
555    line.push_str("}\n");
556    return line;
557}
558
559/// What:
560///  Render a list of events,
561///  numbered consecutively from `first_sequence`.
562///       `&[PolicyEvent]` borrows the list.
563/// Why:
564///   An invocation numbers its events once:
565///  events printed before the pass (a legacy
566///       configuration notice) take the first numbers and the pass continues after them.
567///
568/// In TS you'd write (pseudocode):
569/// ```ts
570/// function renderPolicyEvents(firstSequence: number, events: PolicyEvent[]): string;
571/// ```
572pub fn render_policy_events(first_sequence: u64, events: &[PolicyEvent]) -> String {
573    // `String::new()` is empty owned text.
574    let mut rendered: String = String::new();
575    for (index, event) in events.iter().enumerate() {
576        // `as u64` widens the list index to the event number type.
577        rendered.push_str(render_policy_event(first_sequence + index as u64, event).as_str());
578    }
579    return rendered;
580}
581
582/// Field-order,
583///  escaping and numbering controls stay out of the release executable.
584#[cfg(test)]
585#[path = "policy_events_tests.rs"]
586mod tests;