Skip to main content

git_policy_cli/
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 super::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 super::event_path::{EventPath, base64_standard};
41use super::policy_registry::{PolicyId, Severity, policy_descriptor, severity_name};
42use super::policy_trigger::{Trigger, trigger_name};
43
44/// The code of the warning for a `warn` severity on a policy whose protection `warn` removes.
45pub const WARN_UNSAFE_CODE: &str = "warn-unsafe";
46
47/// What:
48///  A byte range inside a candidate.
49///  A `struct` is a record with named fields;
50///  `u64`
51///       is an unsigned 64-bit integer (siblings `u32`,
52///  `usize`).
53/// Why:
54///   Content policies point at the exact bytes of a finding.
55///  `u64` holds any file
56///       offset on every platform,
57///  where `usize` would be 32 bits on a 32-bit system.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type FindingLocation = { byteStart: number; byteEnd: number };
62/// ```
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct FindingLocation {
65    /// Offset of the first byte.
66    pub byte_start: u64,
67    /// Offset one past the last byte.
68    pub byte_end: u64,
69}
70
71/// What:
72///  One policy finding as it is reported.
73///  `String` is owned UTF-8 text (sibling `&str`
74///       borrows);
75///  `Option<T>` is "a value or nothing";
76///  `&'static str` is text baked into
77///       the program.
78/// Why:
79///   The record owns its message and path because events outlive the check that made
80///       them.
81///  The code is compiled-in text,
82///  so a shipped policy cannot emit an empty or
83///       misspelled code at run time.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type FindingEvent = { trigger; policyId; severity: 'warn' | 'error'; code; message; path?; pathBytes?; location?; fix: 'none' | 'available' };
88/// ```
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct FindingEvent {
91    /// The lifecycle point that ran the policy.
92    pub trigger: Trigger,
93    /// The policy that reported.
94    pub policy: PolicyId,
95    /// The effective severity,
96    ///  `Warn` or `Error`.
97    pub severity: Severity,
98    /// The policy-local code;
99    ///  rendering prefixes the policy name.
100    pub code: &'static str,
101    /// The explanation for the person who ran the command.
102    pub message: String,
103    /// The repository path the finding is about,
104    ///  when it has one.
105    pub path: Option<EventPath>,
106    /// The byte range the finding is about,
107    ///  when it has one.
108    pub location: Option<FindingLocation>,
109    /// Whether the engine holds a correction for this finding.
110    pub fix_available: bool,
111}
112
113/// What:
114///  Every event a policy pass can produce.
115///  An `enum` is a closed set of named
116///       alternatives;
117///  each variant carries its own fields.
118///  `Vec<EventPath>` is an owned
119///       list of pathnames in their event form.
120/// Why:
121///   One list of events is rendered in order,
122///  whatever mixture of kinds it holds.
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// type PolicyEvent = FindingEvent | ConfigurationWarningEvent | CoreFindingEvent | EngineFailureEvent | FixSummaryEvent;
127/// ```
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub enum PolicyEvent {
130    /// A policy reported something.
131    Finding(FindingEvent),
132    /// A policy that `warn` does not protect is configured as `warn`.
133    WarnUnsafe {
134        /// The lifecycle point that ran the policy.
135        trigger: Trigger,
136        /// The policy configured as `warn`.
137        policy: PolicyId,
138    },
139    /// A fixed transform rejected the command;
140    ///  it has no severity and cannot be configured.
141    CoreFinding {
142        /// The transform,
143        ///  for example `commit-only`.
144        core_id: &'static str,
145        /// The transform-local code;
146        ///  rendering prefixes the transform name.
147        code: &'static str,
148        /// The explanation for the person who ran the command.
149        message: String,
150    },
151    /// The pass could not complete.
152    EngineFailure {
153        /// The stable cause.
154        code: EngineFailureCode,
155        /// The explanation for the person who ran the command.
156        message: String,
157        /// The lifecycle point,
158        ///  when the failure belongs to one.
159        trigger: Option<Trigger>,
160        /// The policy that could not complete,
161        ///  when one is responsible.
162        policy: Option<PolicyId>,
163        /// The repository path responsible,
164        ///  when the failure is about one path.
165        path: Option<EventPath>,
166    },
167    /// Corrections were applied.
168    FixSummary {
169        /// The fixing lifecycle point.
170        trigger: Trigger,
171        /// How many passes changed candidate content before it settled.
172        passes: u64,
173        /// The changed paths,
174        ///  each once,
175        ///  in Git's byte order.
176        changed_paths: Vec<EventPath>,
177    },
178    /// The landed commit whose automatic backup the post-commit gate stopped.
179    ///  The commit stays local; the event names it so the user can push it deliberately.
180    CommitLanded {
181        /// The exact landed commit object ID.
182        oid: String,
183    },
184    /// A landing found the target moved (`landing-race-lost`).
185    LandingRaceLost {
186        /// This transaction's lost races so far, from 1.
187        attempt: u64,
188        /// The target value that won.
189        winning_oid: String,
190    },
191    /// A commit that lost enough races holds the next landing slot (`landing-reserved`).
192    LandingReserved {
193        /// Lost races when the reservation was granted.
194        lost_races: u64,
195    },
196    /// A prepared commit was replayed onto the moved target (`commit-replayed`).
197    CommitReplayed {
198        /// The prepared commit.
199        prepared_oid: String,
200        /// The preparation base, absent when the branch was unborn.
201        from_base: Option<String>,
202        /// The target value the replay used as parent.
203        onto: String,
204        /// The replayed commit, after revalidation.
205        oid: String,
206    },
207    /// A signed replay could not carry some custom headers (`replay-headers-dropped`).
208    ReplayHeadersDropped {
209        /// The prepared commit carrying the headers.
210        prepared_oid: String,
211        /// The re-signed commit lacking them.
212        oid: String,
213        /// The dropped header names in prepared-commit order, each once.
214        headers: Vec<String>,
215    },
216    /// The core finding `concurrent-commit/replay-conflict`, with the data a person needs to resolve it.
217    ReplayConflict {
218        /// The target ref the commit aimed at.
219        target_ref: String,
220        /// The conflicting paths in Git path byte order.
221        paths: Vec<String>,
222        /// The earliest target commit touching a conflicting path.
223        winning_oid: String,
224        /// The prepared commit kept for cherry-picking.
225        prepared_oid: String,
226    },
227}
228
229/// What:
230///  Whether an event blocks the command.
231///  `&PolicyEvent` borrows the event read-only.
232/// Why:
233///   An error finding and a fixed-transform rejection keep Git from running;
234///  a
235///       warning finding and a configuration warning do not.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// const blocks = event.type === 'core-finding' || (event.type === 'finding' && event.severity === 'error');
240/// ```
241pub fn event_blocks(event: &PolicyEvent) -> bool {
242    // `match` picks one arm per variant; `{ .. }` ignores the fields a variant carries.
243    match event {
244        PolicyEvent::Finding(finding) => return finding.severity == Severity::Error,
245        PolicyEvent::CoreFinding { .. } | PolicyEvent::ReplayConflict { .. } => return true,
246        PolicyEvent::WarnUnsafe { .. }
247        | PolicyEvent::EngineFailure { .. }
248        | PolicyEvent::FixSummary { .. }
249        | PolicyEvent::CommitLanded { .. }
250        | PolicyEvent::LandingRaceLost { .. }
251        | PolicyEvent::LandingReserved { .. }
252        | PolicyEvent::CommitReplayed { .. }
253        | PolicyEvent::ReplayHeadersDropped { .. } => return false,
254    }
255}
256
257/// What:
258///  The opening every event shares:
259///  schema version,
260///  sequence and type.
261/// Why:
262///   Readers branch on these three fields first,
263///  and they always come first.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// const head = `{"schemaVersion":1,"sequence":${sequence},"type":${JSON.stringify(type)}`;
268/// ```
269fn event_head(sequence: u64, event_type: &str) -> String {
270    // `format!` builds owned text; `{{` is a literal brace.
271    return format!(
272        "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":{}",
273        json_string(event_type)
274    );
275}
276
277/// What:
278///  Append `,"name":"value"` to a line.
279///  `&mut String` lends the line for appending.
280/// Why:
281///   Every text field is encoded at this final step,
282///  so no value can end its JSON
283///       string or its line.
284///
285/// In TS you'd write (pseudocode):
286/// ```ts
287/// line += `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
288/// ```
289fn push_text_field(line: &mut String, name: &str, value: &str) {
290    // `.as_str()` lends the owned text to `push_str`.
291    line.push_str(format!(",\"{name}\":{}", json_string(value)).as_str());
292}
293
294/// What:
295///  Append `,"path":"..."` and,
296///  for a name that is not UTF-8,
297///  `,"pathBytes":"..."`.
298///       `&EventPath` borrows the pathname's event form.
299/// Why:
300///   `path` keeps its meaning for every existing reader;
301///  `pathBytes` is the optional
302///       field schema version 1 allows (`SPEC.md`,
303///  "Unknown fields may be added only in a
304///       backward-compatible schema revision"),
305///  present only when `path` is not exact.
306///
307/// In TS you'd write (pseudocode):
308/// ```ts
309/// line.path = path.text; if (path.exact) line.pathBytes = base64(path.exact);
310/// ```
311fn push_path_field(line: &mut String, path: &EventPath) {
312    push_text_field(line, "path", path.text());
313    // `if let Some(exact) = ...` runs only for a name that kept its bytes.
314    if let Some(exact) = path.exact() {
315        push_text_field(line, "pathBytes", base64_standard(exact).as_str());
316    }
317}
318
319/// What:
320///  Render a finding's fields after the shared opening.
321/// Why:
322///   The order is the incumbent's:
323///  trigger,
324///  policy,
325///  severity,
326///  code,
327///  message,
328///  then the
329///       optional path (with its exact bytes) and location,
330///  then the fix state.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// JSON.stringify({ trigger, policyId, severity, code: `${policyId}/${code}`, message, path, location, fix })
335/// ```
336fn push_finding(line: &mut String, finding: &FindingEvent) {
337    let policy_name: &str = policy_descriptor(finding.policy).name;
338    push_text_field(line, "trigger", trigger_name(finding.trigger));
339    push_text_field(line, "policyId", policy_name);
340    push_text_field(line, "severity", severity_name(finding.severity));
341    push_text_field(
342        line,
343        "code",
344        format!("{policy_name}/{}", finding.code).as_str(),
345    );
346    push_text_field(line, "message", finding.message.as_str());
347    // `if let Some(path) = &finding.path` borrows the path only when one is present.
348    if let Some(path) = &finding.path {
349        push_path_field(line, path);
350    }
351    if let Some(location) = finding.location {
352        line.push_str(
353            format!(
354                ",\"location\":{{\"byteStart\":{},\"byteEnd\":{}}}",
355                location.byte_start, location.byte_end
356            )
357            .as_str(),
358        );
359    }
360    let fix: &str = if finding.fix_available {
361        "available"
362    } else {
363        "none"
364    };
365    push_text_field(line, "fix", fix);
366}
367
368/// What:
369///  Render one JSON string per changed path:
370///  its text,
371///  or the base64 of its bytes.
372///       `fn(&EventPath) -> String` is a plain function pointer choosing what each entry holds.
373/// Why:
374///   A fix summary lists every path it changed,
375///  and its byte list lines up with that
376///       list entry for entry.
377///
378/// In TS you'd write (pseudocode):
379/// ```ts
380/// JSON.stringify(changedPaths.map(entry))
381/// ```
382fn path_array(paths: &[EventPath], entry: fn(&EventPath) -> String) -> String {
383    // `String::from` copies the borrowed text into an owned, growable line.
384    let mut array: String = String::from("[");
385    // `.iter().enumerate()` yields `(index, path)` pairs in order.
386    for (index, path) in paths.iter().enumerate() {
387        if index > 0 {
388            array.push(',');
389        }
390        array.push_str(json_string(entry(path).as_str()).as_str());
391    }
392    array.push(']');
393    return array;
394}
395
396/// What:
397///  A JSON array of strings.
398/// Why:
399///   The replay events carry lists of names in the incumbent's `JSON.stringify` form.
400///
401/// In TS you'd write (pseudocode):
402/// ```ts
403/// JSON.stringify(names);
404/// ```
405fn string_array(names: &[String]) -> String {
406    let mut array: String = String::from("[");
407    for (index, name) in names.iter().enumerate() {
408        if index > 0 {
409            array.push(',');
410        }
411        array.push_str(json_string(name.as_str()).as_str());
412    }
413    array.push(']');
414    return array;
415}
416
417/// What:
418///  The readable text of one changed path,
419///  as a `changedPaths` entry.
420/// Why:
421///   A named function for `path_array`,
422///  because the repository bans anonymous functions.
423///
424/// In TS you'd write (pseudocode):
425/// ```ts
426/// const pathText = (path: EventPath) => path.text;
427/// ```
428fn path_text(path: &EventPath) -> String {
429    return String::from(path.text());
430}
431
432/// What:
433///  The base64 of one changed path's bytes,
434///  as a `changedPathBytes` entry.
435/// Why:
436///   Names that are UTF-8 are listed too,
437///  so index `n` of both lists is the same file.
438///
439/// In TS you'd write (pseudocode):
440/// ```ts
441/// const pathBytes = (path: EventPath) => base64(path.bytes);
442/// ```
443fn path_bytes(path: &EventPath) -> String {
444    return base64_standard(path.bytes());
445}
446
447/// What:
448///  Append `,"changedPaths":[...]` and,
449///  when any of them is not UTF-8,
450///       `,"changedPathBytes":[...]` with one base64 entry per path.
451/// Why:
452///   An array cannot hold an absent entry without JSON `null`,
453///  which events never
454///       carry,
455///  so the byte list names every path once it is needed at all,
456///  and is
457///       absent while every name is exact.
458///
459/// In TS you'd write (pseudocode):
460/// ```ts
461/// line.changedPaths = paths.map(p => p.text); if (paths.some(p => p.exact)) line.changedPathBytes = paths.map(p => base64(p.bytes));
462/// ```
463fn push_changed_paths(line: &mut String, paths: &[EventPath]) {
464    line.push_str(format!(",\"changedPaths\":{}", path_array(paths, path_text)).as_str());
465    for path in paths {
466        if path.exact().is_some() {
467            line.push_str(
468                format!(",\"changedPathBytes\":{}", path_array(paths, path_bytes)).as_str(),
469            );
470            return;
471        }
472    }
473}
474
475/// What:
476///  The wire spelling of an event's kind.
477/// Why:
478///   The `type` field is the third field of every line and decides how a reader
479///       interprets the rest.
480///
481/// In TS you'd write (pseudocode):
482/// ```ts
483/// const type = event.type;
484/// ```
485fn event_type(event: &PolicyEvent) -> &'static str {
486    match event {
487        PolicyEvent::Finding(_) => return "finding",
488        PolicyEvent::WarnUnsafe { .. } => return "configuration-warning",
489        PolicyEvent::CoreFinding { .. } => return "core-finding",
490        PolicyEvent::EngineFailure { .. } => return "engine-failure",
491        PolicyEvent::FixSummary { .. } => return "fix-summary",
492        PolicyEvent::CommitLanded { .. } => return "commit-landed",
493        PolicyEvent::LandingRaceLost { .. } => return "landing-race-lost",
494        PolicyEvent::LandingReserved { .. } => return "landing-reserved",
495        PolicyEvent::CommitReplayed { .. } => return "commit-replayed",
496        PolicyEvent::ReplayHeadersDropped { .. } => return "replay-headers-dropped",
497        PolicyEvent::ReplayConflict { .. } => return "core-finding",
498    }
499}
500
501/// What:
502///  Render one event as a line-terminated JSON object.
503///  `&PolicyEvent` borrows the
504///       event;
505///  the result is owned text.
506/// Why:
507///   One function owns every field order,
508///  so two events of one kind can never differ
509///       in shape.
510///
511/// In TS you'd write (pseudocode):
512/// ```ts
513/// function renderPolicyEvent(sequence: number, event: PolicyEvent): string { return JSON.stringify({ ...event, sequence }) + '\n'; }
514/// ```
515pub fn render_policy_event(sequence: u64, event: &PolicyEvent) -> String {
516    // `mut` allows appending the fields of the matched variant.
517    let mut line: String = event_head(sequence, event_type(event));
518    match event {
519        // `&mut line` lends the line for appending.
520        PolicyEvent::Finding(finding) => push_finding(&mut line, finding),
521        PolicyEvent::WarnUnsafe { trigger, policy } => {
522            let policy_name: &str = policy_descriptor(*policy).name;
523            // `*trigger` copies the small value out of the borrowed event.
524            push_text_field(&mut line, "trigger", trigger_name(*trigger));
525            push_text_field(&mut line, "policyId", policy_name);
526            push_text_field(&mut line, "code", WARN_UNSAFE_CODE);
527            push_text_field(
528                &mut line,
529                "message",
530                format!("Policy {policy_name} is warn-unsafe but configured as warn.").as_str(),
531            );
532        }
533        PolicyEvent::CoreFinding {
534            core_id,
535            code,
536            message,
537        } => {
538            push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
539            push_text_field(&mut line, "coreId", core_id);
540            push_text_field(&mut line, "code", format!("{core_id}/{code}").as_str());
541            push_text_field(&mut line, "message", message.as_str());
542        }
543        PolicyEvent::EngineFailure {
544            code,
545            message,
546            trigger,
547            policy,
548            path,
549        } => {
550            push_text_field(&mut line, "code", engine_failure_code_name(*code));
551            push_text_field(&mut line, "message", message.as_str());
552            if let Some(found) = trigger {
553                push_text_field(&mut line, "trigger", trigger_name(*found));
554            }
555            if let Some(found) = policy {
556                push_text_field(&mut line, "policyId", policy_descriptor(*found).name);
557            }
558            if let Some(found) = path {
559                push_path_field(&mut line, found);
560            }
561        }
562        PolicyEvent::FixSummary {
563            trigger,
564            passes,
565            changed_paths,
566        } => {
567            push_text_field(&mut line, "trigger", trigger_name(*trigger));
568            line.push_str(format!(",\"passes\":{passes}").as_str());
569            push_changed_paths(&mut line, changed_paths.as_slice());
570        }
571        PolicyEvent::CommitLanded { oid } => {
572            push_text_field(&mut line, "oid", oid);
573            push_text_field(&mut line, "outcome", "post-commit-blocked");
574            push_text_field(
575                &mut line,
576                "message",
577                format!("Commit {oid} remains local; post-commit gate blocked automatic backup.")
578                    .as_str(),
579            );
580        }
581        PolicyEvent::LandingRaceLost { attempt, winning_oid } => {
582            line.push_str(format!(",\"attempt\":{attempt}").as_str());
583            push_text_field(&mut line, "winningOid", winning_oid);
584        }
585        PolicyEvent::LandingReserved { lost_races } => {
586            line.push_str(format!(",\"lostRaces\":{lost_races}").as_str());
587        }
588        PolicyEvent::CommitReplayed {
589            prepared_oid,
590            from_base,
591            onto,
592            oid,
593        } => {
594            push_text_field(&mut line, "preparedOid", prepared_oid);
595            if let Some(base) = from_base {
596                push_text_field(&mut line, "fromBase", base);
597            }
598            push_text_field(&mut line, "onto", onto);
599            push_text_field(&mut line, "oid", oid);
600        }
601        PolicyEvent::ReplayHeadersDropped {
602            prepared_oid,
603            oid,
604            headers,
605        } => {
606            push_text_field(&mut line, "preparedOid", prepared_oid);
607            push_text_field(&mut line, "oid", oid);
608            line.push_str(format!(",\"headers\":{}", string_array(headers.as_slice())).as_str());
609            let plural: &str = if headers.len() == 1 { "" } else { "s" };
610            push_text_field(
611                &mut line,
612                "message",
613                format!(
614                    "Replaying signed commit {prepared_oid} as {oid} dropped its {} header{plural}, because Git can re-sign only the headers git commit-tree writes.",
615                    headers.join(", ")
616                )
617                .as_str(),
618            );
619        }
620        PolicyEvent::ReplayConflict {
621            target_ref,
622            paths,
623            winning_oid,
624            prepared_oid,
625        } => {
626            push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
627            push_text_field(&mut line, "coreId", "concurrent-commit");
628            push_text_field(&mut line, "code", "concurrent-commit/replay-conflict");
629            push_text_field(
630                &mut line,
631                "message",
632                format!(
633                    "{target_ref} moved while this commit was prepared, and replaying it conflicts with {winning_oid} in {}; nothing landed. The prepared commit {prepared_oid} stays in the object store until gc expires it: resolve with git cherry-pick {prepared_oid}.",
634                    paths.join(", ")
635                )
636                .as_str(),
637            );
638            line.push_str(format!(",\"paths\":{}", string_array(paths.as_slice())).as_str());
639            push_text_field(&mut line, "winningOid", winning_oid);
640            push_text_field(&mut line, "preparedOid", prepared_oid);
641        }
642    }
643    line.push_str("}\n");
644    return line;
645}
646
647/// What:
648///  Render a list of events,
649///  numbered consecutively from `first_sequence`.
650///       `&[PolicyEvent]` borrows the list.
651/// Why:
652///   An invocation numbers its events once:
653///  events printed before the pass (a legacy
654///       configuration notice) take the first numbers and the pass continues after them.
655///
656/// In TS you'd write (pseudocode):
657/// ```ts
658/// function renderPolicyEvents(firstSequence: number, events: PolicyEvent[]): string;
659/// ```
660pub fn render_policy_events(first_sequence: u64, events: &[PolicyEvent]) -> String {
661    // `String::new()` is empty owned text.
662    let mut rendered: String = String::new();
663    for (index, event) in events.iter().enumerate() {
664        // `as u64` widens the list index to the event number type.
665        rendered.push_str(render_policy_event(first_sequence + index as u64, event).as_str());
666    }
667    return rendered;
668}
669
670/// Field-order,
671///  escaping and numbering controls stay out of the release executable.
672#[cfg(test)]
673#[path = "policy_events_tests.rs"]
674mod tests;