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, as the exact bytes Git reported.
221 paths: Vec<EventPath>,
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 a list of paths as `,"<text_key>":[...]` and,
449/// when any of them is not UTF-8,
450/// `,"<bytes_key>":[...]` 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[textKey] = paths.map(p => p.text); if (paths.some(p => p.exact)) line[bytesKey] = paths.map(p => base64(p.bytes));
462/// ```
463fn push_path_list(line: &mut String, text_key: &str, bytes_key: &str, paths: &[EventPath]) {
464 line.push_str(format!(",\"{text_key}\":{}", path_array(paths, path_text)).as_str());
465 for path in paths {
466 if path.exact().is_some() {
467 line.push_str(format!(",\"{bytes_key}\":{}", path_array(paths, path_bytes)).as_str());
468 return;
469 }
470 }
471}
472
473/// What:
474/// The wire spelling of an event's kind.
475/// Why:
476/// The `type` field is the third field of every line and decides how a reader
477/// interprets the rest.
478///
479/// In TS you'd write (pseudocode):
480/// ```ts
481/// const type = event.type;
482/// ```
483fn event_type(event: &PolicyEvent) -> &'static str {
484 match event {
485 PolicyEvent::Finding(_) => return "finding",
486 PolicyEvent::WarnUnsafe { .. } => return "configuration-warning",
487 PolicyEvent::CoreFinding { .. } => return "core-finding",
488 PolicyEvent::EngineFailure { .. } => return "engine-failure",
489 PolicyEvent::FixSummary { .. } => return "fix-summary",
490 PolicyEvent::CommitLanded { .. } => return "commit-landed",
491 PolicyEvent::LandingRaceLost { .. } => return "landing-race-lost",
492 PolicyEvent::LandingReserved { .. } => return "landing-reserved",
493 PolicyEvent::CommitReplayed { .. } => return "commit-replayed",
494 PolicyEvent::ReplayHeadersDropped { .. } => return "replay-headers-dropped",
495 PolicyEvent::ReplayConflict { .. } => return "core-finding",
496 }
497}
498
499/// What:
500/// Render one event as a line-terminated JSON object.
501/// `&PolicyEvent` borrows the
502/// event;
503/// the result is owned text.
504/// Why:
505/// One function owns every field order,
506/// so two events of one kind can never differ
507/// in shape.
508///
509/// In TS you'd write (pseudocode):
510/// ```ts
511/// function renderPolicyEvent(sequence: number, event: PolicyEvent): string { return JSON.stringify({ ...event, sequence }) + '\n'; }
512/// ```
513pub fn render_policy_event(sequence: u64, event: &PolicyEvent) -> String {
514 // `mut` allows appending the fields of the matched variant.
515 let mut line: String = event_head(sequence, event_type(event));
516 match event {
517 // `&mut line` lends the line for appending.
518 PolicyEvent::Finding(finding) => push_finding(&mut line, finding),
519 PolicyEvent::WarnUnsafe { trigger, policy } => {
520 let policy_name: &str = policy_descriptor(*policy).name;
521 // `*trigger` copies the small value out of the borrowed event.
522 push_text_field(&mut line, "trigger", trigger_name(*trigger));
523 push_text_field(&mut line, "policyId", policy_name);
524 push_text_field(&mut line, "code", WARN_UNSAFE_CODE);
525 push_text_field(
526 &mut line,
527 "message",
528 format!("Policy {policy_name} is warn-unsafe but configured as warn.").as_str(),
529 );
530 }
531 PolicyEvent::CoreFinding {
532 core_id,
533 code,
534 message,
535 } => {
536 push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
537 push_text_field(&mut line, "coreId", core_id);
538 push_text_field(&mut line, "code", format!("{core_id}/{code}").as_str());
539 push_text_field(&mut line, "message", message.as_str());
540 }
541 PolicyEvent::EngineFailure {
542 code,
543 message,
544 trigger,
545 policy,
546 path,
547 } => {
548 push_text_field(&mut line, "code", engine_failure_code_name(*code));
549 push_text_field(&mut line, "message", message.as_str());
550 if let Some(found) = trigger {
551 push_text_field(&mut line, "trigger", trigger_name(*found));
552 }
553 if let Some(found) = policy {
554 push_text_field(&mut line, "policyId", policy_descriptor(*found).name);
555 }
556 if let Some(found) = path {
557 push_path_field(&mut line, found);
558 }
559 }
560 PolicyEvent::FixSummary {
561 trigger,
562 passes,
563 changed_paths,
564 } => {
565 push_text_field(&mut line, "trigger", trigger_name(*trigger));
566 line.push_str(format!(",\"passes\":{passes}").as_str());
567 push_path_list(&mut line, "changedPaths", "changedPathBytes", changed_paths.as_slice());
568 }
569 PolicyEvent::CommitLanded { oid } => {
570 push_text_field(&mut line, "oid", oid);
571 push_text_field(&mut line, "outcome", "post-commit-blocked");
572 push_text_field(
573 &mut line,
574 "message",
575 format!("Commit {oid} remains local; post-commit gate blocked automatic backup.")
576 .as_str(),
577 );
578 }
579 PolicyEvent::LandingRaceLost { attempt, winning_oid } => {
580 line.push_str(format!(",\"attempt\":{attempt}").as_str());
581 push_text_field(&mut line, "winningOid", winning_oid);
582 }
583 PolicyEvent::LandingReserved { lost_races } => {
584 line.push_str(format!(",\"lostRaces\":{lost_races}").as_str());
585 }
586 PolicyEvent::CommitReplayed {
587 prepared_oid,
588 from_base,
589 onto,
590 oid,
591 } => {
592 push_text_field(&mut line, "preparedOid", prepared_oid);
593 if let Some(base) = from_base {
594 push_text_field(&mut line, "fromBase", base);
595 }
596 push_text_field(&mut line, "onto", onto);
597 push_text_field(&mut line, "oid", oid);
598 }
599 PolicyEvent::ReplayHeadersDropped {
600 prepared_oid,
601 oid,
602 headers,
603 } => {
604 push_text_field(&mut line, "preparedOid", prepared_oid);
605 push_text_field(&mut line, "oid", oid);
606 line.push_str(format!(",\"headers\":{}", string_array(headers.as_slice())).as_str());
607 let plural: &str = if headers.len() == 1 { "" } else { "s" };
608 push_text_field(
609 &mut line,
610 "message",
611 format!(
612 "Replaying signed commit {prepared_oid} as {oid} dropped its {} header{plural}, because Git can re-sign only the headers git commit-tree writes.",
613 headers.join(", ")
614 )
615 .as_str(),
616 );
617 }
618 PolicyEvent::ReplayConflict {
619 target_ref,
620 paths,
621 winning_oid,
622 prepared_oid,
623 } => {
624 push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
625 push_text_field(&mut line, "coreId", "concurrent-commit");
626 push_text_field(&mut line, "code", "concurrent-commit/replay-conflict");
627 push_text_field(
628 &mut line,
629 "message",
630 format!(
631 "{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}.",
632 paths.iter().map(EventPath::text).collect::<Vec<&str>>().join(", ")
633 )
634 .as_str(),
635 );
636 push_path_list(&mut line, "paths", "pathsBytes", paths.as_slice());
637 push_text_field(&mut line, "winningOid", winning_oid);
638 push_text_field(&mut line, "preparedOid", prepared_oid);
639 }
640 }
641 line.push_str("}\n");
642 return line;
643}
644
645/// What:
646/// Render a list of events,
647/// numbered consecutively from `first_sequence`.
648/// `&[PolicyEvent]` borrows the list.
649/// Why:
650/// An invocation numbers its events once:
651/// events printed before the pass (a legacy
652/// configuration notice) take the first numbers and the pass continues after them.
653///
654/// In TS you'd write (pseudocode):
655/// ```ts
656/// function renderPolicyEvents(firstSequence: number, events: PolicyEvent[]): string;
657/// ```
658pub fn render_policy_events(first_sequence: u64, events: &[PolicyEvent]) -> String {
659 // `String::new()` is empty owned text.
660 let mut rendered: String = String::new();
661 for (index, event) in events.iter().enumerate() {
662 // `as u64` widens the list index to the event number type.
663 rendered.push_str(render_policy_event(first_sequence + index as u64, event).as_str());
664 }
665 return rendered;
666}
667
668/// Field-order,
669/// escaping and numbering controls stay out of the release executable.
670#[cfg(test)]
671#[path = "policy_events_tests.rs"]
672mod tests;