Skip to main content

git_policy_cli/
blob_batch.rs

1//! What:
2//!  Loading many blobs through one `git cat-file --batch`,
3//!  optionally from a private object
4//!       store,
5//!  with strict parsing of the batch output.
6//! Why:
7//!  Post-landing completion reads the original and intended bytes of every added path;
8//!  one
9//!      batch replaces a Git start per object (`src/policy-engine/blob-batch.ts`).
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const blobs = await loadBlobBatch({ gitPath, cwd, oids, objectDirectory, createError });
14//! ```
15
16/// Running real Git.
17use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
18/// `HashMap` maps object IDs to bytes.
19use std::collections::HashMap;
20/// `Path` is a borrowed filesystem path.
21use std::path::Path;
22
23/// What:
24///  One successful batch header:
25///  the object ID and its byte length.
26/// Why:
27///   `parseBatchHeader`:
28///  `<oid> blob <size>`,
29///  exactly three space-separated fields,
30///  a blob,
31///       and a canonical decimal size.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// function parseBatchHeader({ header }): { oid: string; size: number };
36/// ```
37pub fn parse_batch_header(header: &str) -> Result<(String, usize), String> {
38    let fields: Vec<&str> = header.split(' ').collect();
39    if fields.len() != 3 {
40        return Err(format!(
41            "Git blob batch returned malformed header: {header}"
42        ));
43    }
44    let (oid, kind, size_text) = (fields[0], fields[1], fields[2]);
45    if kind != "blob" {
46        return Err(format!("Git blob batch returned {kind} for {oid}."));
47    }
48    let canonical: bool = !size_text.is_empty()
49        && size_text.bytes().all(is_ascii_digit)
50        && (size_text == "0" || !size_text.starts_with('0'));
51    let parsed: Option<i64> = if canonical {
52        size_text.parse::<i64>().ok()
53    } else {
54        None
55    };
56    if let Some(found) = parsed
57        && found <= super::json_record::MAX_SAFE_INTEGER
58        && let Ok(size) = usize::try_from(found)
59    {
60        return Ok((String::from(oid), size));
61    }
62    return Err(format!(
63        "Git blob batch returned invalid size for {oid}: {size_text}"
64    ));
65}
66
67/// What:
68///  Whether a byte is an ASCII digit.
69/// Why:
70///   A named predicate keeps the scan free of closures.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// (byte: number) => byte >= 0x30 && byte <= 0x39
75/// ```
76fn is_ascii_digit(byte: u8) -> bool {
77    return byte.is_ascii_digit();
78}
79
80/// What:
81///  Parse ordered batch output for the requested objects,
82///  never reading blob bytes as
83///       delimiters.
84/// Why:
85///   `parseBatchOutput`:
86///  every header names the requested object,
87///  every content ends with a
88///       newline,
89///  and nothing trails.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// function parseBatchOutput({ output, requestedOids }): ReadonlyMap<string, Uint8Array>;
94/// ```
95pub fn parse_batch_output(
96    output: &[u8],
97    requested: &[String],
98) -> Result<HashMap<String, Vec<u8>>, String> {
99    // `mut` allows filling the map and advancing the cursor.
100    let mut blobs: HashMap<String, Vec<u8>> = HashMap::new();
101    let mut offset: usize = 0;
102    for oid in requested {
103        let Some(relative) = output[offset..].iter().position(is_newline) else {
104            return Err(format!("Git blob batch omitted header for {oid}."));
105        };
106        let header_end: usize = offset + relative;
107        let Ok(header) = std::str::from_utf8(&output[offset..header_end]) else {
108            return Err(format!(
109                "Git blob batch returned a header that is not UTF-8 for {oid}."
110            ));
111        };
112        let (found, size) = parse_batch_header(header)?;
113        if &found != oid {
114            return Err(format!(
115                "Git blob batch returned {found} while {oid} was requested."
116            ));
117        }
118        let start: usize = header_end + 1;
119        let end: usize = match start.checked_add(size) {
120            Some(value) if value < output.len() && output[value] == b'\n' => value,
121            _ => {
122                return Err(format!(
123                    "Git blob batch returned truncated content for {oid}."
124                ));
125            }
126        };
127        blobs.insert(oid.clone(), output[start..end].to_vec());
128        offset = end + 1;
129    }
130    if offset != output.len() {
131        return Err(String::from(
132            "Git blob batch returned unexpected trailing bytes.",
133        ));
134    }
135    return Ok(blobs);
136}
137
138/// What:
139///  Whether a byte is a newline.
140/// Why:
141///   A named predicate keeps the search free of closures.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// (byte: number) => byte === 0x0a
146/// ```
147fn is_newline(byte: &u8) -> bool {
148    return *byte == b'\n';
149}
150
151/// What:
152///  Load every distinct requested blob through one batch,
153///  optionally reading a private
154///       object store (whose alternates still reach the real one).
155/// Why:
156///   `loadBlobBatch`;
157///  request order is first-seen order.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// async function loadBlobBatch({ gitPath, cwd, oids, objectDirectory }): Promise<ReadonlyMap<string, Uint8Array>>;
162/// ```
163pub fn load_blob_batch(
164    context: &GitContext,
165    cwd: &Path,
166    oids: &[String],
167    object_directory: Option<&Path>,
168) -> Result<HashMap<String, Vec<u8>>, String> {
169    // `mut` allows collecting distinct IDs in order.
170    let mut requested: Vec<String> = Vec::new();
171    for oid in oids {
172        if !requested.contains(oid) {
173            requested.push(oid.clone());
174        }
175    }
176    if requested.is_empty() {
177        return Ok(HashMap::new());
178    }
179    let mut input: String = requested.join("\n");
180    input.push('\n');
181    let mut request: GitRequest = GitRequest::new(cwd, &["cat-file", "--batch"]);
182    request.input = Some(input.into_bytes());
183    request.object_directory = object_directory.map(Path::to_path_buf);
184    let output: GitOutput = match run_git(context, &request) {
185        Ok(finished) => finished,
186        Err(error) => return Err(format!("git cat-file --batch could not start: {error}")),
187    };
188    if !output.succeeded() {
189        return Err(format!(
190            "git cat-file --batch failed: {}",
191            output.error_text()
192        ));
193    }
194    return parse_batch_output(output.stdout.as_slice(), requested.as_slice());
195}
196
197/// Batch controls stay out of the release executable.
198#[cfg(test)]
199#[path = "blob_batch_tests.rs"]
200mod tests;