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_hints::{
41 POST_COMMIT_BLOCKED_HINT, WARN_UNSAFE_HINT, engine_failure_hint, finding_hint,
42 push_hint_field,
43};
44use super::event_path::{EventPath, base64_standard};
45use super::policy_registry::{PolicyId, Severity, policy_descriptor, severity_name};
46use super::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/// Every event a policy pass can produce.
119/// An `enum` is a closed set of named
120/// alternatives;
121/// each variant carries its own fields.
122/// `Vec<EventPath>` is an owned
123/// list of pathnames in their event form.
124/// Why:
125/// One list of events is rendered in order,
126/// whatever mixture of kinds it holds.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// type PolicyEvent = FindingEvent | ConfigurationWarningEvent | CoreFindingEvent | EngineFailureEvent | FixSummaryEvent;
131/// ```
132#[derive(Clone, Debug, Eq, PartialEq)]
133pub enum PolicyEvent {
134 /// A policy reported something.
135 Finding(FindingEvent),
136 /// A policy that `warn` does not protect is configured as `warn`.
137 WarnUnsafe {
138 /// The lifecycle point that ran the policy.
139 trigger: Trigger,
140 /// The policy configured as `warn`.
141 policy: PolicyId,
142 },
143 /// A fixed transform rejected the command;
144 /// it has no severity and cannot be configured.
145 CoreFinding {
146 /// The transform,
147 /// for example `commit-only`.
148 core_id: &'static str,
149 /// The transform-local code;
150 /// rendering prefixes the transform name.
151 code: &'static str,
152 /// The explanation for the person who ran the command.
153 message: String,
154 },
155 /// The pass could not complete.
156 EngineFailure {
157 /// The stable cause.
158 code: EngineFailureCode,
159 /// The explanation for the person who ran the command.
160 message: String,
161 /// The lifecycle point,
162 /// when the failure belongs to one.
163 trigger: Option<Trigger>,
164 /// The policy that could not complete,
165 /// when one is responsible.
166 policy: Option<PolicyId>,
167 /// The repository path responsible,
168 /// when the failure is about one path.
169 path: Option<EventPath>,
170 },
171 /// Corrections were applied.
172 FixSummary {
173 /// The fixing lifecycle point.
174 trigger: Trigger,
175 /// How many passes changed candidate content before it settled.
176 passes: u64,
177 /// The changed paths,
178 /// each once,
179 /// in Git's byte order.
180 changed_paths: Vec<EventPath>,
181 },
182 /// The landed commit whose automatic backup the post-commit gate stopped.
183 /// The commit stays local; the event names it so the user can push it deliberately.
184 CommitLanded {
185 /// The exact landed commit object ID.
186 oid: String,
187 },
188}
189
190/// What:
191/// Whether an event blocks the command.
192/// `&PolicyEvent` borrows the event read-only.
193/// Why:
194/// An error finding and a fixed-transform rejection keep Git from running;
195/// a
196/// warning finding and a configuration warning do not.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// const blocks = event.type === 'core-finding' || (event.type === 'finding' && event.severity === 'error');
201/// ```
202pub fn event_blocks(event: &PolicyEvent) -> bool {
203 // `match` picks one arm per variant; `{ .. }` ignores the fields a variant carries.
204 match event {
205 PolicyEvent::Finding(finding) => return finding.severity == Severity::Error,
206 PolicyEvent::CoreFinding { .. } => return true,
207 PolicyEvent::WarnUnsafe { .. }
208 | PolicyEvent::EngineFailure { .. }
209 | PolicyEvent::FixSummary { .. }
210 | PolicyEvent::CommitLanded { .. } => return false,
211 }
212}
213
214/// What:
215/// The opening every event shares:
216/// schema version,
217/// sequence and type.
218/// Why:
219/// Readers branch on these three fields first,
220/// and they always come first.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// const head = `{"schemaVersion":1,"sequence":${sequence},"type":${JSON.stringify(type)}`;
225/// ```
226fn event_head(sequence: u64, event_type: &str) -> String {
227 // `format!` builds owned text; `{{` is a literal brace.
228 return format!(
229 "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":{}",
230 json_string(event_type)
231 );
232}
233
234/// What:
235/// Append `,"name":"value"` to a line.
236/// `&mut String` lends the line for appending.
237/// Why:
238/// Every text field is encoded at this final step,
239/// so no value can end its JSON
240/// string or its line.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// line += `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
245/// ```
246fn push_text_field(line: &mut String, name: &str, value: &str) {
247 // `.as_str()` lends the owned text to `push_str`.
248 line.push_str(format!(",\"{name}\":{}", json_string(value)).as_str());
249}
250
251/// What:
252/// Append `,"path":"..."` and,
253/// for a name that is not UTF-8,
254/// `,"pathBytes":"..."`.
255/// `&EventPath` borrows the pathname's event form.
256/// Why:
257/// `path` keeps its meaning for every existing reader;
258/// `pathBytes` is the optional
259/// field schema version 1 allows (`SPEC.md`,
260/// "Unknown fields may be added only in a
261/// backward-compatible schema revision"),
262/// present only when `path` is not exact.
263///
264/// In TS you'd write (pseudocode):
265/// ```ts
266/// line.path = path.text; if (path.exact) line.pathBytes = base64(path.exact);
267/// ```
268fn push_path_field(line: &mut String, path: &EventPath) {
269 push_text_field(line, "path", path.text());
270 // `if let Some(exact) = ...` runs only for a name that kept its bytes.
271 if let Some(exact) = path.exact() {
272 push_text_field(line, "pathBytes", base64_standard(exact).as_str());
273 }
274}
275
276/// What:
277/// Render a finding's fields after the shared opening.
278/// Why:
279/// The order is the incumbent's:
280/// trigger,
281/// policy,
282/// severity,
283/// code,
284/// message,
285/// then the
286/// optional path (with its exact bytes) and location,
287/// then the fix state.
288///
289/// In TS you'd write (pseudocode):
290/// ```ts
291/// JSON.stringify({ trigger, policyId, severity, code: `${policyId}/${code}`, message, path, location, fix })
292/// ```
293fn push_finding(line: &mut String, finding: &FindingEvent) {
294 let policy_name: &str = policy_descriptor(finding.policy).name;
295 push_text_field(line, "trigger", trigger_name(finding.trigger));
296 push_text_field(line, "policyId", policy_name);
297 push_text_field(line, "severity", severity_name(finding.severity));
298 push_text_field(
299 line,
300 "code",
301 format!("{policy_name}/{}", finding.code).as_str(),
302 );
303 push_text_field(line, "message", finding.message.as_str());
304 push_hint_field(line, finding_hint(policy_name, finding.code, finding.fix_available));
305 // `if let Some(path) = &finding.path` borrows the path only when one is present.
306 if let Some(path) = &finding.path {
307 push_path_field(line, path);
308 }
309 if let Some(location) = finding.location {
310 line.push_str(
311 format!(
312 ",\"location\":{{\"byteStart\":{},\"byteEnd\":{}}}",
313 location.byte_start, location.byte_end
314 )
315 .as_str(),
316 );
317 }
318 let fix: &str = if finding.fix_available {
319 "available"
320 } else {
321 "none"
322 };
323 push_text_field(line, "fix", fix);
324}
325
326/// What:
327/// Render one JSON string per changed path:
328/// its text,
329/// or the base64 of its bytes.
330/// `fn(&EventPath) -> String` is a plain function pointer choosing what each entry holds.
331/// Why:
332/// A fix summary lists every path it changed,
333/// and its byte list lines up with that
334/// list entry for entry.
335///
336/// In TS you'd write (pseudocode):
337/// ```ts
338/// JSON.stringify(changedPaths.map(entry))
339/// ```
340fn path_array(paths: &[EventPath], entry: fn(&EventPath) -> String) -> String {
341 // `String::from` copies the borrowed text into an owned, growable line.
342 let mut array: String = String::from("[");
343 // `.iter().enumerate()` yields `(index, path)` pairs in order.
344 for (index, path) in paths.iter().enumerate() {
345 if index > 0 {
346 array.push(',');
347 }
348 array.push_str(json_string(entry(path).as_str()).as_str());
349 }
350 array.push(']');
351 return array;
352}
353
354/// What:
355/// The readable text of one changed path,
356/// as a `changedPaths` entry.
357/// Why:
358/// A named function for `path_array`,
359/// because the repository bans anonymous functions.
360///
361/// In TS you'd write (pseudocode):
362/// ```ts
363/// const pathText = (path: EventPath) => path.text;
364/// ```
365fn path_text(path: &EventPath) -> String {
366 return String::from(path.text());
367}
368
369/// What:
370/// The base64 of one changed path's bytes,
371/// as a `changedPathBytes` entry.
372/// Why:
373/// Names that are UTF-8 are listed too,
374/// so index `n` of both lists is the same file.
375///
376/// In TS you'd write (pseudocode):
377/// ```ts
378/// const pathBytes = (path: EventPath) => base64(path.bytes);
379/// ```
380fn path_bytes(path: &EventPath) -> String {
381 return base64_standard(path.bytes());
382}
383
384/// What:
385/// Append a list of paths as `,"<text_key>":[...]` and,
386/// when any of them is not UTF-8,
387/// `,"<bytes_key>":[...]` with one base64 entry per path.
388/// Why:
389/// An array cannot hold an absent entry without JSON `null`,
390/// which events never
391/// carry,
392/// so the byte list names every path once it is needed at all,
393/// and is
394/// absent while every name is exact.
395///
396/// In TS you'd write (pseudocode):
397/// ```ts
398/// line[textKey] = paths.map(p => p.text); if (paths.some(p => p.exact)) line[bytesKey] = paths.map(p => base64(p.bytes));
399/// ```
400fn push_path_list(line: &mut String, text_key: &str, bytes_key: &str, paths: &[EventPath]) {
401 line.push_str(format!(",\"{text_key}\":{}", path_array(paths, path_text)).as_str());
402 for path in paths {
403 if path.exact().is_some() {
404 line.push_str(format!(",\"{bytes_key}\":{}", path_array(paths, path_bytes)).as_str());
405 return;
406 }
407 }
408}
409
410/// What:
411/// The wire spelling of an event's kind.
412/// Why:
413/// The `type` field is the third field of every line and decides how a reader
414/// interprets the rest.
415///
416/// In TS you'd write (pseudocode):
417/// ```ts
418/// const type = event.type;
419/// ```
420fn event_type(event: &PolicyEvent) -> &'static str {
421 match event {
422 PolicyEvent::Finding(_) => return "finding",
423 PolicyEvent::WarnUnsafe { .. } => return "configuration-warning",
424 PolicyEvent::CoreFinding { .. } => return "core-finding",
425 PolicyEvent::EngineFailure { .. } => return "engine-failure",
426 PolicyEvent::FixSummary { .. } => return "fix-summary",
427 PolicyEvent::CommitLanded { .. } => return "commit-landed",
428 }
429}
430
431/// What:
432/// Render one event as a line-terminated JSON object.
433/// `&PolicyEvent` borrows the
434/// event;
435/// the result is owned text.
436/// Why:
437/// One function owns every field order,
438/// so two events of one kind can never differ
439/// in shape.
440///
441/// In TS you'd write (pseudocode):
442/// ```ts
443/// function renderPolicyEvent(sequence: number, event: PolicyEvent): string { return JSON.stringify({ ...event, sequence }) + '\n'; }
444/// ```
445pub fn render_policy_event(sequence: u64, event: &PolicyEvent) -> String {
446 // `mut` allows appending the fields of the matched variant.
447 let mut line: String = event_head(sequence, event_type(event));
448 match event {
449 // `&mut line` lends the line for appending.
450 PolicyEvent::Finding(finding) => push_finding(&mut line, finding),
451 PolicyEvent::WarnUnsafe { trigger, policy } => {
452 let policy_name: &str = policy_descriptor(*policy).name;
453 // `*trigger` copies the small value out of the borrowed event.
454 push_text_field(&mut line, "trigger", trigger_name(*trigger));
455 push_text_field(&mut line, "policyId", policy_name);
456 push_text_field(&mut line, "code", WARN_UNSAFE_CODE);
457 push_text_field(
458 &mut line,
459 "message",
460 format!("Policy {policy_name} is warn-unsafe but configured as warn.").as_str(),
461 );
462 push_hint_field(&mut line, Some(WARN_UNSAFE_HINT));
463 }
464 PolicyEvent::CoreFinding {
465 core_id,
466 code,
467 message,
468 } => {
469 push_text_field(&mut line, "trigger", trigger_name(Trigger::PreForward));
470 push_text_field(&mut line, "coreId", core_id);
471 push_text_field(&mut line, "code", format!("{core_id}/{code}").as_str());
472 push_text_field(&mut line, "message", message.as_str());
473 }
474 PolicyEvent::EngineFailure {
475 code,
476 message,
477 trigger,
478 policy,
479 path,
480 } => {
481 let code_name: &str = engine_failure_code_name(*code);
482 push_text_field(&mut line, "code", code_name);
483 push_text_field(&mut line, "message", message.as_str());
484 push_hint_field(&mut line, engine_failure_hint(code_name));
485 if let Some(found) = trigger {
486 push_text_field(&mut line, "trigger", trigger_name(*found));
487 }
488 if let Some(found) = policy {
489 push_text_field(&mut line, "policyId", policy_descriptor(*found).name);
490 }
491 if let Some(found) = path {
492 push_path_field(&mut line, found);
493 }
494 }
495 PolicyEvent::FixSummary {
496 trigger,
497 passes,
498 changed_paths,
499 } => {
500 push_text_field(&mut line, "trigger", trigger_name(*trigger));
501 line.push_str(format!(",\"passes\":{passes}").as_str());
502 push_path_list(&mut line, "changedPaths", "changedPathBytes", changed_paths.as_slice());
503 }
504 PolicyEvent::CommitLanded { oid } => {
505 push_text_field(&mut line, "oid", oid);
506 push_text_field(&mut line, "outcome", "post-commit-blocked");
507 push_text_field(
508 &mut line,
509 "message",
510 format!("Commit {oid} remains local; post-commit gate blocked automatic backup.")
511 .as_str(),
512 );
513 push_hint_field(&mut line, Some(POST_COMMIT_BLOCKED_HINT));
514 }
515 }
516 line.push_str("}\n");
517 return line;
518}
519
520/// What:
521/// Render a list of events,
522/// numbered consecutively from `first_sequence`.
523/// `&[PolicyEvent]` borrows the list.
524/// Why:
525/// An invocation numbers its events once:
526/// events printed before the pass (a legacy
527/// configuration notice) take the first numbers and the pass continues after them.
528///
529/// In TS you'd write (pseudocode):
530/// ```ts
531/// function renderPolicyEvents(firstSequence: number, events: PolicyEvent[]): string;
532/// ```
533pub fn render_policy_events(first_sequence: u64, events: &[PolicyEvent]) -> String {
534 // `String::new()` is empty owned text.
535 let mut rendered: String = String::new();
536 for (index, event) in events.iter().enumerate() {
537 // `as u64` widens the list index to the event number type.
538 rendered.push_str(render_policy_event(first_sequence + index as u64, event).as_str());
539 }
540 return rendered;
541}
542
543/// Field-order,
544/// escaping and numbering controls stay out of the release executable.
545#[cfg(test)]
546#[path = "policy_events_tests.rs"]
547mod tests;