Skip to main content

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;