Skip to main content

diffs_rs/
ingest.rs

1use std::io::BufRead;
2
3use anyhow::{Context, Result, bail};
4
5/// Splits Git patch text into file-patch units and checks their hunk boundaries.
6///
7/// Each unit starts at a `diff --git` line. `visit` receives the unit's byte offset in the
8/// original stream and its exact bytes. Text outside units is skipped: commit headers and
9/// messages from `git show` and `git log -p`, mail headers and `-- ` signatures from
10/// `git format-patch`, and diffstats from `--stat -p`. The skipped bytes still count toward
11/// offsets, so callers that address the original stream stay exact. Returns the total number
12/// of bytes read.
13///
14/// Like `git apply`, a unit's extended header ends at the first line that is not a Git header
15/// line, and a valid hunk header outside a unit is an error. A complete hunk followed by another
16/// source record remains an error, so incorrect hunk counts cannot silently drop source lines.
17/// Input containing only whitespace yields no units; other text without a `diff --git` line
18/// is an error.
19pub fn visit_files(
20    mut input: impl BufRead,
21    mut visit: impl FnMut(u64, &[u8]) -> Result<()>,
22) -> Result<u64> {
23    let mut splitter = Splitter::default();
24    let mut line = Vec::new();
25    loop {
26        line.clear();
27        if input.read_until(b'\n', &mut line)? == 0 {
28            break;
29        }
30        splitter.line(&line, &mut visit)?;
31    }
32    splitter.finish(&mut visit)
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36enum State {
37    /// Text outside any file patch. Records whether the previous line began with `--- `.
38    Outside { old_file_header: bool },
39    /// Git extended header lines after `diff --git`, before any hunk.
40    Header,
41    /// Remaining old/new line counts of the current hunk; `(0, 0)` once it is complete.
42    Hunk(u64, u64),
43    /// A `GIT binary patch` body with its completed binary hunks and whether data lines follow.
44    Binary { hunks: u8, data: bool },
45    /// A complete binary patch. Only another file patch or outside text can follow.
46    Ended,
47}
48
49impl Default for State {
50    fn default() -> Self {
51        Self::Outside {
52            old_file_header: false,
53        }
54    }
55}
56
57#[derive(Default)]
58struct Splitter {
59    state: State,
60    unit: Vec<u8>,
61    unit_start: u64,
62    offset: u64,
63    units: usize,
64    text_outside: bool,
65}
66
67const NO_NEWLINE: &[u8] = b"\\ No newline at end of file";
68
69/// Extended header lines that Git and libgit2 recognize after `diff --git`.
70const HEADER_LINES: &[&[u8]] = &[
71    b"--- ",
72    b"+++ ",
73    b"old mode ",
74    b"new mode ",
75    b"deleted file mode ",
76    b"new file mode ",
77    b"copy from ",
78    b"copy to ",
79    b"rename old ",
80    b"rename new ",
81    b"rename from ",
82    b"rename to ",
83    b"similarity index ",
84    b"dissimilarity index ",
85    b"index ",
86    b"Binary files ",
87];
88
89impl Splitter {
90    fn line(
91        &mut self,
92        line: &[u8],
93        visit: &mut impl FnMut(u64, &[u8]) -> Result<()>,
94    ) -> Result<()> {
95        if line.starts_with(b"diff --cc ") || line.starts_with(b"diff --combined ") {
96            bail!("Combined merge diffs are not supported. Supply a two-sided patch.");
97        }
98        let file_header = line.starts_with(b"diff --git ");
99        let content = trim_eol(line);
100        loop {
101            match self.state {
102                State::Outside { old_file_header } => {
103                    if file_header {
104                        self.begin(line);
105                        return Ok(());
106                    }
107                    if old_file_header && line.starts_with(b"+++ ") {
108                        bail!(
109                            "Cannot parse this patch: file headers without a `diff --git` line. Supply Git patch output."
110                        );
111                    }
112                    if line.starts_with(b"@@ -") && hunk_counts(line).is_ok() {
113                        bail!("Cannot parse this patch: hunk header outside a file patch");
114                    }
115                    self.text_outside |= !line.iter().all(u8::is_ascii_whitespace);
116                    self.state = State::Outside {
117                        old_file_header: line.starts_with(b"--- "),
118                    };
119                    self.offset += line.len() as u64;
120                    return Ok(());
121                }
122                State::Header => {
123                    if file_header {
124                        self.end(visit)?;
125                        self.begin(line);
126                        return Ok(());
127                    }
128                    if line.starts_with(b"@@ ") {
129                        let (old, new) = hunk_counts(line)?;
130                        self.state = State::Hunk(old, new);
131                    } else if content == b"GIT binary patch" {
132                        self.state = State::Binary {
133                            hunks: 0,
134                            data: false,
135                        };
136                    } else if !HEADER_LINES.iter().any(|prefix| line.starts_with(prefix)) {
137                        self.end(visit)?;
138                        continue;
139                    }
140                    self.push(line);
141                    return Ok(());
142                }
143                State::Hunk(0, 0) => {
144                    if file_header {
145                        self.end(visit)?;
146                        self.begin(line);
147                        return Ok(());
148                    }
149                    if line.starts_with(b"@@ ") {
150                        let (old, new) = hunk_counts(line)?;
151                        self.state = State::Hunk(old, new);
152                    } else if content == NO_NEWLINE {
153                        // The parser validates the marker's association with a source line.
154                    } else if content == b"-- " {
155                        // The `git format-patch` signature separator ends the last file patch.
156                        self.end(visit)?;
157                        continue;
158                    } else if matches!(line.first(), Some(b' ' | b'+' | b'-')) {
159                        bail!("Unexpected source record or incorrect hunk counts");
160                    } else {
161                        self.end(visit)?;
162                        continue;
163                    }
164                    self.push(line);
165                    return Ok(());
166                }
167                State::Hunk(ref mut old, ref mut new) => {
168                    if file_header || line.starts_with(b"@@ ") {
169                        bail!("Hunk counts do not agree with its source lines");
170                    }
171                    if content != NO_NEWLINE {
172                        match line.first() {
173                            Some(b' ') if *old > 0 && *new > 0 => {
174                                *old -= 1;
175                                *new -= 1;
176                            }
177                            Some(b'-') if *old > 0 => *old -= 1,
178                            Some(b'+') if *new > 0 => *new -= 1,
179                            _ => bail!("Unexpected source record or incorrect hunk counts"),
180                        }
181                    }
182                    self.push(line);
183                    return Ok(());
184                }
185                State::Binary { hunks, data } => {
186                    let blank = content.is_empty();
187                    let hunk_header = line.starts_with(b"literal ") || line.starts_with(b"delta ");
188                    if !data && hunks > 0 && !hunk_header {
189                        // Git writes a forward and an optional reverse hunk, each ending in a blank line.
190                        self.state = State::Ended;
191                        continue;
192                    }
193                    self.state = match (data, blank) {
194                        (true, true) if hunks > 0 => State::Ended,
195                        (true, true) => State::Binary {
196                            hunks: hunks + 1,
197                            data: false,
198                        },
199                        // The parser validates binary hunk headers and data lines.
200                        _ => State::Binary { hunks, data: true },
201                    };
202                    self.push(line);
203                    return Ok(());
204                }
205                State::Ended => {
206                    if file_header {
207                        self.end(visit)?;
208                        self.begin(line);
209                        return Ok(());
210                    }
211                    self.end(visit)?;
212                }
213            }
214        }
215    }
216
217    fn begin(&mut self, line: &[u8]) {
218        self.unit_start = self.offset;
219        self.state = State::Header;
220        self.push(line);
221    }
222
223    fn push(&mut self, line: &[u8]) {
224        self.unit.extend_from_slice(line);
225        self.offset += line.len() as u64;
226    }
227
228    /// Publishes the current unit and returns to outside text.
229    fn end(&mut self, visit: &mut impl FnMut(u64, &[u8]) -> Result<()>) -> Result<()> {
230        if let State::Hunk(old, new) = self.state
231            && (old, new) != (0, 0)
232        {
233            bail!("Hunk counts do not agree with its source lines");
234        }
235        self.state = State::default();
236        if !self.unit.is_empty() {
237            visit(self.unit_start, &self.unit)?;
238            self.unit.clear();
239            self.units += 1;
240        }
241        Ok(())
242    }
243
244    fn finish(mut self, visit: &mut impl FnMut(u64, &[u8]) -> Result<()>) -> Result<u64> {
245        self.end(visit)?;
246        if self.units == 0 && self.text_outside {
247            bail!(
248                "Cannot parse this patch: no `diff --git` file header found. Supply Git patch output."
249            );
250        }
251        Ok(self.offset)
252    }
253}
254
255fn trim_eol(line: &[u8]) -> &[u8] {
256    let line = line.strip_suffix(b"\n").unwrap_or(line);
257    line.strip_suffix(b"\r").unwrap_or(line)
258}
259
260fn hunk_counts(header: &[u8]) -> Result<(u64, u64)> {
261    let header = header.strip_suffix(b"\n").unwrap_or(header);
262    let mut parts = header.split(|b| *b == b' ');
263    anyhow::ensure!(
264        parts.next() == Some(b"@@".as_slice()),
265        "Invalid hunk header"
266    );
267    let count = |range: Option<&[u8]>, sign: u8| -> Result<u64> {
268        let range = range.context("Missing hunk range")?;
269        anyhow::ensure!(range.first() == Some(&sign), "Invalid hunk range");
270        let mut values = range[1..].split(|b| *b == b',');
271        let start = values.next().context("Missing hunk start")?;
272        std::str::from_utf8(start)?.parse::<u64>()?;
273        let count = match values.next() {
274            Some(count) => std::str::from_utf8(count)?.parse()?,
275            None => 1,
276        };
277        anyhow::ensure!(values.next().is_none(), "Invalid hunk range");
278        Ok(count)
279    };
280    let old = count(parts.next(), b'-')?;
281    let new = count(parts.next(), b'+')?;
282    anyhow::ensure!(
283        parts.next() == Some(b"@@".as_slice()),
284        "Invalid hunk header"
285    );
286    Ok((old, new))
287}
288
289#[cfg(test)]
290mod tests;