git_policy_cli/replay_object.rs
1//! What:
2//! Raw commit objects as exact bytes:
3//! parsing, the unsigned replay rewrite, and the inputs a signed replay passes to `git commit-tree`.
4//! Why:
5//! Replay keeps every byte of the prepared commit except its `tree` and `parent` lines,
6//! so headers and message stay bytes;
7//! a non-UTF-8 `encoding` commit round-trips unchanged.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! parseRawCommit(bytes); rewriteCommitObject({ commit, treeOid, parentOid });
12//! ```
13
14/// Owned byte and text values.
15use std::fmt;
16
17/// What:
18/// One header with its continuation lines.
19/// Why:
20/// The rewrite copies each header's bytes unchanged.
21///
22/// In TS you'd write (pseudocode):
23/// ```ts
24/// type RawCommitHeader = { name: string; bytes: Uint8Array };
25/// ```
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct RawCommitHeader {
28 /// Header name, Latin-1 decoded, before the first space.
29 pub name: String,
30 /// Complete header bytes, continuation lines and final newline included.
31 pub bytes: Vec<u8>,
32}
33
34/// What:
35/// A parsed raw commit.
36/// Why:
37/// Replay reads the tree, the encoding, whether the object is signed,
38/// and the identities; the message and headers are copied back as bytes.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type RawCommit = { headers; message: Uint8Array; treeOid: string; encoding?: string; signed: boolean };
43/// ```
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct RawCommit {
46 /// Headers in object order.
47 pub headers: Vec<RawCommitHeader>,
48 /// Message bytes after the blank line.
49 pub message: Vec<u8>,
50 /// Tree header value.
51 pub tree_oid: String,
52 /// `encoding` header value, absent for UTF-8.
53 pub encoding: Option<String>,
54 /// Whether a `gpgsig` or `gpgsig-sha256` header is present.
55 pub signed: bool,
56}
57
58/// What:
59/// A raw commit that cannot be replayed:
60/// no `tree` header, or an identity header that is missing or malformed.
61/// Why:
62/// Replay fails the transaction rather than guess at identities.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// class ReplayObjectError extends TypeError {}
67/// ```
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct ReplayObjectError(pub String);
70
71impl fmt::Display for ReplayObjectError {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 return write!(formatter, "{}", self.0);
74 }
75}
76
77/// Headers a signed replay reproduces through `git commit-tree`; every other header is dropped there.
78const REBUILT_HEADERS: [&str; 7] = [
79 "tree",
80 "parent",
81 "author",
82 "committer",
83 "encoding",
84 "gpgsig",
85 "gpgsig-sha256",
86];
87
88/// What:
89/// The text value of a single-line header,
90/// without its name and trailing newline, decoded as UTF-8 with replacement.
91/// Why:
92/// The incumbent decodes header values with `toString('utf8')`,
93/// which replaces invalid sequences rather than failing.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// Buffer.from(header.bytes).toString('utf8', name.length + 1, bytes.length - 1)
98/// ```
99fn header_value(header: &RawCommitHeader) -> String {
100 let start: usize = (header.name.len() + 1).min(header.bytes.len());
101 let stop: usize = header.bytes.len().saturating_sub(1).max(start);
102 return String::from_utf8_lossy(&header.bytes[start..stop]).into_owned();
103}
104
105/// What:
106/// Splits the header section into headers,
107/// joining each continuation line (one starting with a space) to the header it continues.
108/// Why:
109/// A signature spans many lines; each header must keep all of them to round-trip.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// splitHeaders(headerBytes);
114/// ```
115fn split_headers(header_bytes: &[u8]) -> Vec<RawCommitHeader> {
116 if header_bytes.is_empty() {
117 return Vec::new();
118 }
119 // Every line start, the first byte included, except after a final newline.
120 let mut line_starts: Vec<usize> = vec![0];
121 for (index, byte) in header_bytes.iter().enumerate() {
122 if *byte == b'\n' && index + 1 < header_bytes.len() {
123 line_starts.push(index + 1);
124 }
125 }
126 let header_starts: Vec<usize> = line_starts
127 .into_iter()
128 .filter(|start| { return header_bytes[*start] != b' '; })
129 .collect();
130 let mut headers: Vec<RawCommitHeader> = Vec::new();
131 for (index, start) in header_starts.iter().enumerate() {
132 let stop: usize = header_starts.get(index + 1).copied().unwrap_or(header_bytes.len());
133 let bytes: Vec<u8> = header_bytes[*start..stop].to_vec();
134 let name_end: usize = match bytes.iter().position(|byte| { return *byte == b' '; }) {
135 Some(space) => space,
136 None => bytes.len() - 1,
137 };
138 headers.push(RawCommitHeader {
139 name: bytes[..name_end].iter().map(|byte| { return char::from(*byte); }).collect(),
140 bytes,
141 });
142 }
143 return headers;
144}
145
146/// What:
147/// Parses a raw commit object from `git cat-file commit` output.
148/// Why:
149/// Replay reads the tree, encoding, and signature state, and keeps the rest as bytes.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// parseRawCommit(bytes); // throws TypeError without a tree header
154/// ```
155pub fn parse_raw_commit(bytes: &[u8]) -> Result<RawCommit, ReplayObjectError> {
156 let blank: Option<usize> = bytes.windows(2).position(|pair| { return pair == b"\n\n"; });
157 let (header_bytes, message): (&[u8], Vec<u8>) = match blank {
158 Some(index) => (&bytes[..index + 1], bytes[index + 2..].to_vec()),
159 None => (bytes, Vec::new()),
160 };
161 let headers: Vec<RawCommitHeader> = split_headers(header_bytes);
162 let tree: &RawCommitHeader = match headers.iter().find(|header| { return header.name == "tree"; }) {
163 Some(found) => found,
164 None => return Err(ReplayObjectError(String::from("Commit object has no tree header."))),
165 };
166 let tree_oid: String = header_value(tree);
167 let encoding: Option<String> = headers
168 .iter()
169 .find(|header| { return header.name == "encoding"; })
170 .map(header_value);
171 let signed: bool = headers
172 .iter()
173 .any(|header| { return header.name == "gpgsig" || header.name == "gpgsig-sha256"; });
174 return Ok(RawCommit {
175 headers,
176 message,
177 tree_oid,
178 encoding,
179 signed,
180 });
181}
182
183/// What:
184/// Rewrites an unsigned commit onto a new tree and parent, keeping every other byte.
185/// Why:
186/// The replayed object must differ from the prepared one only in `tree` and `parent`,
187/// so the message, encoding, identities, and custom headers stay byte-identical.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// rewriteCommitObject({ commit, treeOid, parentOid });
192/// ```
193pub fn rewrite_commit_object(commit: &RawCommit, tree_oid: &str, parent_oid: &str) -> Vec<u8> {
194 // `mut` lets the output be assembled in header order.
195 let mut output: Vec<u8> = Vec::new();
196 for header in &commit.headers {
197 match header.name.as_str() {
198 "tree" => output.extend_from_slice(format!("tree {tree_oid}\nparent {parent_oid}\n").as_bytes()),
199 "parent" => {}
200 _ => output.extend_from_slice(&header.bytes),
201 }
202 }
203 output.push(b'\n');
204 output.extend_from_slice(&commit.message);
205 return output;
206}
207
208/// What:
209/// The names of headers a signed replay drops, each once in object order.
210/// Why:
211/// Replay reports them with `replay-headers-dropped`,
212/// so the operator learns a hook or tool added headers that `git commit-tree` cannot reproduce.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// droppedReplayHeaders(commit); // ['x-custom']
217/// ```
218pub fn dropped_replay_headers(commit: &RawCommit) -> Vec<String> {
219 // `mut` lets the names be collected once each, in object order.
220 let mut names: Vec<String> = Vec::new();
221 for header in &commit.headers {
222 if !REBUILT_HEADERS.contains(&header.name.as_str()) && !names.contains(&header.name) {
223 names.push(header.name.clone());
224 }
225 }
226 return names;
227}
228
229/// What:
230/// The Git identity environment of one `author` or `committer` header, as `git commit-tree` reads it:
231/// the name, the email, and the date as `@<seconds> <zone>`.
232/// Why:
233/// A signed replay reproduces the prepared commit's identities and dates through these variables.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// identityEnvironment({ commit, role: 'author' }); // GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_AUTHOR_DATE
238/// ```
239pub fn identity_environment(
240 commit: &RawCommit,
241 role: &str,
242) -> Result<Vec<(String, String)>, ReplayObjectError> {
243 let header: &RawCommitHeader = match commit.headers.iter().find(|candidate| { return candidate.name == role; }) {
244 Some(found) => found,
245 None => {
246 return Err(ReplayObjectError(format!("Commit object has no {role} header.")));
247 }
248 };
249 let value: String = header_value(header);
250 let malformed = || {
251 return ReplayObjectError(format!(
252 "Commit {role} header is malformed: {}",
253 serde_json::to_string(&value).unwrap_or_default()
254 ));
255 };
256 let open: usize = value.rfind('<').ok_or_else(malformed)?;
257 let close: usize = open + value[open..].find('>').ok_or_else(malformed)?;
258 let prefix: String = format!("GIT_{}", role.to_uppercase());
259 return Ok(vec![
260 (format!("{prefix}_NAME"), value[..open].trim_end().to_string()),
261 (format!("{prefix}_EMAIL"), value[open + 1..close].to_string()),
262 (format!("{prefix}_DATE"), format!("@{}", value[close + 1..].trim())),
263 ]);
264}
265
266#[cfg(test)]
267#[path = "replay_object_tests.rs"]
268mod tests;