Skip to main content

git_policy_cli/
repo_read_object.rs

1//! What:
2//!  The shapes of a Git object read in process, object names as bytes and hexadecimal, and the zlib step.
3//! Why:
4//!  Loose objects and pack entries are zlib streams; every reader of this family shares one inflate call
5//!  (a pure Rust decoder) and one definition of an object name, so a size or format mismatch is refused in one place.
6//!
7//! In TS you'd write (pseudocode):
8//! ```ts
9//! type RawObject = { kind: 'commit' | 'tree' | 'blob' | 'tag'; bytes: Uint8Array };
10//! ```
11
12use super::repo_read_layout::ObjectFormat;
13use miniz_oxide::inflate::TINFLStatus;
14use miniz_oxide::inflate::core::{DecompressorOxide, decompress, inflate_flags};
15
16/// The largest object this family inflates; anything bigger is left to Git.
17pub const OBJECT_SIZE_LIMIT: usize = 256 << 20;
18
19/// The four kinds of Git object.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum ObjectKind {
22    /// A commit.
23    Commit,
24    /// A tree.
25    Tree,
26    /// A blob.
27    Blob,
28    /// An annotated tag.
29    Tag,
30}
31
32impl ObjectKind {
33    /// The word Git writes in an object header and `cat-file -t` prints.
34    pub fn word(self) -> &'static str {
35        return match self {
36            ObjectKind::Commit => "commit",
37            ObjectKind::Tree => "tree",
38            ObjectKind::Blob => "blob",
39            ObjectKind::Tag => "tag",
40        };
41    }
42
43    /// The kind a loose-object header word names.
44    pub fn from_word(word: &[u8]) -> Option<ObjectKind> {
45        return match word {
46            b"commit" => Some(ObjectKind::Commit),
47            b"tree" => Some(ObjectKind::Tree),
48            b"blob" => Some(ObjectKind::Blob),
49            b"tag" => Some(ObjectKind::Tag),
50            _ => None,
51        };
52    }
53}
54
55/// One object with its kind and its content, without the header.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct RawObject {
58    /// What the object is.
59    pub kind: ObjectKind,
60    /// The content bytes.
61    pub bytes: Vec<u8>,
62}
63
64/// The length in bytes of an object name.
65pub fn name_length(format: ObjectFormat) -> usize {
66    return format.hex_length() / 2;
67}
68
69/// The lower-case hexadecimal spelling of an object name.
70pub fn to_hex(name: &[u8]) -> String {
71    let mut text: String = String::with_capacity(name.len() * 2);
72    for byte in name {
73        text.push(char::from(b"0123456789abcdef"[usize::from(byte >> 4)]));
74        text.push(char::from(b"0123456789abcdef"[usize::from(byte & 15)]));
75    }
76    return text;
77}
78
79/// The raw object name a hexadecimal spelling of the right length stands for.
80pub fn from_hex(text: &[u8], format: ObjectFormat) -> Option<Vec<u8>> {
81    if text.len() != format.hex_length() {
82        return None;
83    }
84    let digit = |byte: u8| -> Option<u8> {
85        return match byte {
86            b'0'..=b'9' => Some(byte - b'0'),
87            b'a'..=b'f' => Some(byte - b'a' + 10),
88            _ => None,
89        };
90    };
91    let mut name: Vec<u8> = Vec::with_capacity(text.len() / 2);
92    for pair in text.chunks(2) {
93        name.push(digit(pair[0])? << 4 | digit(pair[1])?);
94    }
95    return Some(name);
96}
97
98/// The most compressed bytes of one stream the inflater looks at; a longer stream is left to Git.
99///  A block that produces nothing still costs a table rebuild in the decoder (upstream issue 198 of the decoder crate),
100///  so the cost of a hostile stream grows with its length, not with its output; real commits, trees and small blobs
101///  are far below the bound, and Git inflates the rest.
102pub const INFLATE_INPUT_LIMIT: usize = 512 << 10;
103
104/// The smallest output buffer the inflater starts with, so an empty input still grows.
105const FIRST_OUTPUT_BYTES: usize = 64;
106
107/// What:
108///  Inflate the zlib stream at the start of `input` into at most `limit` bytes, and report how many input bytes the stream
109///  used, its checksum included.
110/// Why:
111///   Git refuses a loose object with bytes after its stream (`garbage at end of loose object`), so the loose reader needs
112///  the length, which the decoder's convenience functions do not return; a pack window runs past its stream on purpose.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// const { output, consumed } = inflateZlib(input, limit); // undefined: bad, short or longer than the limit
117/// ```
118pub fn inflate_zlib(whole_input: &[u8], limit: usize) -> Option<(Vec<u8>, usize)> {
119    let input: &[u8] = &whole_input[..whole_input.len().min(INFLATE_INPUT_LIMIT)];
120    let flags: u32 = inflate_flags::TINFL_FLAG_PARSE_ZLIB_HEADER | inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF;
121    let mut output: Vec<u8> = vec![0; input.len().saturating_mul(2).min(limit)];
122    let mut state: Box<DecompressorOxide> = Box::<DecompressorOxide>::default();
123    let mut produced: usize = 0;
124    let mut used: usize = 0;
125    loop {
126        let (status, consumed, written) = decompress(&mut state, input.get(used..)?, &mut output, produced, flags);
127        used = used.checked_add(consumed)?;
128        produced = produced.checked_add(written)?;
129        match status {
130            TINFLStatus::Done => {
131                output.truncate(produced);
132                return Some((output, used));
133            }
134            TINFLStatus::HasMoreOutput => {
135                if output.len() >= limit {
136                    return None;
137                }
138                let grown: usize = output.len().saturating_mul(2).max(FIRST_OUTPUT_BYTES).min(limit);
139                output.resize(grown, 0);
140            }
141            _ => return None,
142        }
143    }
144}
145
146/// What:
147///  Read a loose object file's bytes: the zlib stream of `<kind> <size>\0<content>`; the kind and content come back
148///  only when the header's size equals the content length.
149/// Why:
150///   A loose file that disagrees with its own header is corrupt, and Git words that failure itself.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// const object = parseLoose(await readFile(path)); // undefined: ask Git
155/// ```
156pub fn parse_loose(file_bytes: &[u8]) -> Option<RawObject> {
157    let (whole, consumed): (Vec<u8>, usize) = inflate_zlib(file_bytes, OBJECT_SIZE_LIMIT + 32)?;
158    // Bytes after the stream are Git's `garbage at end of loose object`.
159    if consumed != file_bytes.len() {
160        return None;
161    }
162    let nul: usize = whole.iter().position(|byte| { return *byte == 0; })?;
163    let header: &[u8] = &whole[..nul];
164    let space: usize = header.iter().position(|byte| { return *byte == b' '; })?;
165    let kind: ObjectKind = ObjectKind::from_word(&header[..space])?;
166    let size_text: &str = std::str::from_utf8(&header[space + 1..]).ok()?;
167    if size_text.is_empty() || (size_text.len() > 1 && size_text.starts_with('0')) || !size_text.bytes().all(|byte| { return byte.is_ascii_digit(); }) {
168        return None;
169    }
170    let size: usize = size_text.parse::<usize>().ok()?;
171    let content: &[u8] = &whole[nul + 1..];
172    if content.len() != size {
173        return None;
174    }
175    return Some(RawObject { kind, bytes: content.to_vec() });
176}
177
178/// What:
179///  Inflate a zlib stream whose result must be exactly `expected` bytes; trailing input is ignored.
180/// Why:
181///   Pack entries do not record their compressed length, so the caller hands a window that may run past the stream.
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// const out = inflateZlib(window, expected); // undefined when the stream is bad, short or too long
186/// ```
187pub fn inflate_exact(window: &[u8], expected: usize) -> Option<Vec<u8>> {
188    if expected > OBJECT_SIZE_LIMIT {
189        return None;
190    }
191    let (inflated, _): (Vec<u8>, usize) = inflate_zlib(window, expected)?;
192    if inflated.len() != expected {
193        return None;
194    }
195    return Some(inflated);
196}
197
198#[cfg(test)]
199#[path = "repo_read_object_tests.rs"]
200mod tests;