Skip to main content

grit_lib/
fsck_standalone.rs

1//! Standalone object fsck for `hash-object` and similar entry points.
2//!
3//! Mirrors the buffer-safe checks in Git's `fsck.c` (`verify_headers`,
4//! `fsck_commit`, `fsck_tag_standalone`, `fsck_tree`) so error messages match
5//! `error: object fails fsck: <camelCaseId>: <detail>`.
6
7use crate::check_ref_format::{check_refname_format, RefNameOptions};
8use crate::git_date::tm::date_overflows;
9use crate::objects::{ObjectId, ObjectKind};
10
11/// Git-compatible fsck failure for loose object validation.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FsckError {
14    /// CamelCase message id (e.g. `missingTree`).
15    pub id: &'static str,
16    /// Human-readable detail after `id: `.
17    pub detail: String,
18}
19
20impl FsckError {
21    /// Construct an fsck diagnostic (library tests and `mktag` use this for uniform messages).
22    #[must_use]
23    pub fn new(id: &'static str, detail: impl Into<String>) -> Self {
24        Self {
25            id,
26            detail: detail.into(),
27        }
28    }
29
30    /// Full line after `error: object fails fsck: ` (matches Git).
31    #[must_use]
32    pub fn report_line(&self) -> String {
33        format!("{}: {}", self.id, self.detail)
34    }
35}
36
37/// Validate raw object bytes the same way `git hash-object` does before hashing.
38///
39/// Returns `Ok(())` when the object is well-formed, or the first fsck error Git
40/// would report for truncated or malformed buffers.
41pub fn fsck_object(kind: ObjectKind, data: &[u8]) -> Result<(), FsckError> {
42    match kind {
43        ObjectKind::Blob => Ok(()),
44        ObjectKind::Commit => fsck_commit(data),
45        ObjectKind::Tag => fsck_tag(data),
46        ObjectKind::Tree => fsck_tree(data),
47    }
48}
49
50fn verify_headers(data: &[u8], nul_msg_id: &'static str) -> Result<(), FsckError> {
51    for (i, &b) in data.iter().enumerate() {
52        if b == 0 {
53            return Err(FsckError::new(
54                nul_msg_id,
55                format!("unterminated header: NUL at offset {i}"),
56            ));
57        }
58        if b == b'\n' && i + 1 < data.len() && data[i + 1] == b'\n' {
59            return Ok(());
60        }
61    }
62    if !data.is_empty() && data[data.len() - 1] == b'\n' {
63        Ok(())
64    } else {
65        Err(FsckError::new("unterminatedHeader", "unterminated header"))
66    }
67}
68
69fn is_hex_lower(b: u8) -> bool {
70    matches!(b, b'0'..=b'9' | b'a'..=b'f')
71}
72
73/// Parse a lowercase hex object id at the start of `buf` (40 chars for SHA-1 or
74/// 64 for SHA-256), requiring the next byte to be `\n`. Returns bytes consumed
75/// (hex width + 1).
76fn parse_oid_line(buf: &[u8], bad_sha1_id: &'static str) -> Result<usize, FsckError> {
77    let bad = || {
78        FsckError::new(
79            bad_sha1_id,
80            format!(
81                "invalid '{}' line format - bad sha1",
82                line_kind(bad_sha1_id)
83            ),
84        )
85    };
86    // The hex width follows the repository hash (a `\n` terminates the id).
87    let hex_len = buf.iter().position(|&b| b == b'\n').ok_or_else(bad)?;
88    if !ObjectId::is_hex_len(hex_len) {
89        return Err(bad());
90    }
91    let hex = &buf[..hex_len];
92    if !hex.iter().copied().all(is_hex_lower) {
93        return Err(bad());
94    }
95    let hex_str = std::str::from_utf8(hex).map_err(|_| bad())?;
96    hex_str.parse::<ObjectId>().map_err(|_| bad())?;
97    Ok(hex_len + 1)
98}
99
100fn line_kind(bad_sha1_id: &'static str) -> &'static str {
101    match bad_sha1_id {
102        "badObjectSha1" => "object",
103        "badParentSha1" => "parent",
104        _ => "tree",
105    }
106}
107
108fn fsck_ident(
109    data: &[u8],
110    start: usize,
111    buffer_end: usize,
112    oid_line: &'static str,
113) -> Result<usize, FsckError> {
114    let mut p = start;
115    if p >= buffer_end {
116        return Err(FsckError::new(
117            "missingEmail",
118            format!("invalid {oid_line} line - missing email"),
119        ));
120    }
121
122    let line_end = data[p..buffer_end]
123        .iter()
124        .position(|&b| b == b'\n')
125        .map(|rel| p + rel)
126        .ok_or_else(|| {
127            FsckError::new(
128                "missingEmail",
129                format!("invalid {oid_line} line - missing email"),
130            )
131        })?;
132
133    let ident_end = line_end;
134
135    if data[p] == b'<' {
136        return Err(FsckError::new(
137            "missingNameBeforeEmail",
138            format!("invalid {oid_line} line - missing space before email"),
139        ));
140    }
141
142    // Name: scan until '<' (Git `fsck_ident`).
143    loop {
144        if p >= ident_end || data[p] == b'\n' {
145            return Err(FsckError::new(
146                "missingEmail",
147                format!("invalid {oid_line} line - missing email"),
148            ));
149        }
150        if data[p] == b'>' {
151            return Err(FsckError::new(
152                "badName",
153                format!("invalid {oid_line} line - bad name"),
154            ));
155        }
156        if data[p] == b'<' {
157            break;
158        }
159        p += 1;
160    }
161
162    if p == start || data[p - 1] != b' ' {
163        return Err(FsckError::new(
164            "missingSpaceBeforeEmail",
165            format!("invalid {oid_line} line - missing space before email"),
166        ));
167    }
168    p += 1; // skip '<'
169
170    // Email (may be empty between `<>`).
171    loop {
172        if p >= ident_end || data[p] == b'<' || data[p] == b'\n' {
173            return Err(FsckError::new(
174                "badEmail",
175                format!("invalid {oid_line} line - bad email"),
176            ));
177        }
178        if data[p] == b'>' {
179            break;
180        }
181        p += 1;
182    }
183    p += 1; // skip '>'
184
185    if p >= ident_end || data[p] != b' ' {
186        return Err(FsckError::new(
187            "missingSpaceBeforeDate",
188            format!("invalid {oid_line} line - missing space before date"),
189        ));
190    }
191    p += 1;
192
193    while p < ident_end && (data[p] == b' ' || data[p] == b'\t') {
194        p += 1;
195    }
196
197    if p >= ident_end || !data[p].is_ascii_digit() {
198        return Err(FsckError::new(
199            "badDate",
200            format!("invalid {oid_line} line - bad date"),
201        ));
202    }
203
204    if data[p] == b'0' && p + 1 < ident_end && data[p + 1] != b' ' {
205        return Err(FsckError::new(
206            "zeroPaddedDate",
207            format!("invalid {oid_line} line - zero-padded date"),
208        ));
209    }
210
211    let ts_start = p;
212    while p < ident_end && data[p].is_ascii_digit() {
213        p += 1;
214    }
215    let ts_len = p - ts_start;
216    if ts_len > 21 {
217        return Err(FsckError::new(
218            "badDateOverflow",
219            format!("invalid {oid_line} line - date causes integer overflow"),
220        ));
221    }
222    let ts_str = std::str::from_utf8(&data[ts_start..p])
223        .map_err(|_| FsckError::new("badDate", format!("invalid {oid_line} line - bad date")))?;
224    let raw: u128 = ts_str
225        .parse()
226        .map_err(|_| FsckError::new("badDate", format!("invalid {oid_line} line - bad date")))?;
227    if raw > u64::MAX as u128 || date_overflows(raw as u64) {
228        return Err(FsckError::new(
229            "badDateOverflow",
230            format!("invalid {oid_line} line - date causes integer overflow"),
231        ));
232    }
233
234    if p >= ident_end || data[p] != b' ' {
235        return Err(FsckError::new(
236            "badDate",
237            format!("invalid {oid_line} line - bad date"),
238        ));
239    }
240    p += 1;
241
242    // Timezone: `[+-]HHMM` then newline (Git allows e.g. `-1430`).
243    if p + 5 > ident_end
244        || (data[p] != b'+' && data[p] != b'-')
245        || !data[p + 1..p + 5].iter().all(|b| b.is_ascii_digit())
246        || data[p + 5] != b'\n'
247    {
248        return Err(FsckError::new(
249            "badTimezone",
250            format!("invalid {oid_line} line - bad time zone"),
251        ));
252    }
253
254    Ok(line_end + 1)
255}
256
257fn fsck_commit(data: &[u8]) -> Result<(), FsckError> {
258    verify_headers(data, "nulInHeader")?;
259
260    let buffer_end = data.len();
261    let mut i = 0usize;
262
263    if i >= buffer_end || !data[i..].starts_with(b"tree ") {
264        return Err(FsckError::new(
265            "missingTree",
266            "invalid format - expected 'tree' line",
267        ));
268    }
269    i += 5;
270    let n = parse_oid_line(&data[i..], "badTreeSha1")?;
271    i += n;
272
273    while i < buffer_end && data[i..].starts_with(b"parent ") {
274        i += 7;
275        let n = parse_oid_line(&data[i..], "badParentSha1")?;
276        i += n;
277    }
278
279    let mut author_count = 0usize;
280    while i < buffer_end && data[i..].starts_with(b"author ") {
281        author_count += 1;
282        i += 7;
283        i = fsck_ident(data, i, buffer_end, "author/committer")?;
284    }
285
286    if author_count < 1 {
287        return Err(FsckError::new(
288            "missingAuthor",
289            "invalid format - expected 'author' line",
290        ));
291    }
292    if author_count > 1 {
293        return Err(FsckError::new(
294            "multipleAuthors",
295            "invalid format - multiple 'author' lines",
296        ));
297    }
298
299    if i >= buffer_end || !data[i..].starts_with(b"committer ") {
300        return Err(FsckError::new(
301            "missingCommitter",
302            "invalid format - expected 'committer' line",
303        ));
304    }
305    i += 10;
306    fsck_ident(data, i, buffer_end, "author/committer")?;
307
308    if data.contains(&0) {
309        return Err(FsckError::new(
310            "nulInCommit",
311            "NUL byte in the commit object body",
312        ));
313    }
314
315    Ok(())
316}
317
318/// Byte offset immediately after the newline that terminates the `tagger` line.
319fn parse_tag_headers_through_tagger(data: &[u8]) -> Result<usize, FsckError> {
320    verify_headers(data, "nulInHeader")?;
321
322    let buffer_end = data.len();
323    let mut i = 0usize;
324
325    if i >= buffer_end || !data[i..].starts_with(b"object ") {
326        return Err(FsckError::new(
327            "missingObject",
328            "invalid format - expected 'object' line",
329        ));
330    }
331    i += 7;
332    let n = parse_oid_line(&data[i..], "badObjectSha1")?;
333    i += n;
334
335    if i >= buffer_end || !data[i..].starts_with(b"type ") {
336        return Err(FsckError::new(
337            "missingTypeEntry",
338            "invalid format - expected 'type' line",
339        ));
340    }
341    i += 5;
342    let type_start = i;
343    let eol = data[type_start..buffer_end]
344        .iter()
345        .position(|&b| b == b'\n')
346        .map(|rel| type_start + rel)
347        .ok_or_else(|| {
348            FsckError::new(
349                "missingType",
350                "invalid format - unexpected end after 'type' line",
351            )
352        })?;
353
354    if ObjectKind::from_tag_type_field(&data[type_start..eol]).is_none() {
355        return Err(FsckError::new("badType", "invalid 'type' value"));
356    }
357    i = eol + 1;
358
359    if i >= buffer_end || !data[i..].starts_with(b"tag ") {
360        return Err(FsckError::new(
361            "missingTagEntry",
362            "invalid format - expected 'tag' line",
363        ));
364    }
365    i += 4;
366    let tag_start = i;
367    let eol = data[tag_start..buffer_end]
368        .iter()
369        .position(|&b| b == b'\n')
370        .map(|rel| tag_start + rel)
371        .ok_or_else(|| {
372            FsckError::new(
373                "missingTag",
374                "invalid format - unexpected end after 'type' line",
375            )
376        })?;
377
378    let tag_name = std::str::from_utf8(&data[tag_start..eol])
379        .map_err(|_| FsckError::new("badTagName", "invalid 'tag' name"))?;
380    let refname = format!("refs/tags/{tag_name}");
381    if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
382        return Err(FsckError::new(
383            "badTagName",
384            format!("invalid 'tag' name: {tag_name}"),
385        ));
386    }
387    i = eol + 1;
388
389    if i >= buffer_end || !data[i..].starts_with(b"tagger ") {
390        return Err(FsckError::new(
391            "missingTaggerEntry",
392            "invalid format - expected 'tagger' line",
393        ));
394    }
395    i += 7;
396    fsck_ident(data, i, buffer_end, "author/committer")
397}
398
399fn fsck_tag(data: &[u8]) -> Result<(), FsckError> {
400    parse_tag_headers_through_tagger(data).map(|_| ())
401}
402
403/// Parse tag headers for `git mktag`, matching Git `fsck_tag_standalone` severities:
404/// `badTagName` and `missingTaggerEntry` are INFO→WARN: fatal only when `strict` is true.
405///
406/// Returns `(tagged_oid, tagged_type, header_end_offset, check_trailer)`.
407///
408/// When `check_trailer` is true, pass `header_end_offset` to [`fsck_tag_mktag_trailer_from`].
409/// After a lenient recovery from a broken `tagger` line (`--no-strict`), it is false because the
410/// cursor is already past the header/body boundary.
411pub fn parse_tag_for_mktag(
412    data: &[u8],
413    strict: bool,
414    on_warn: &mut impl FnMut(&FsckError),
415) -> Result<(ObjectId, ObjectKind, usize, bool), FsckError> {
416    verify_headers(data, "nulInHeader")?;
417
418    let buffer_end = data.len();
419    let mut i = 0usize;
420
421    if i >= buffer_end || !data[i..].starts_with(b"object ") {
422        return Err(FsckError::new(
423            "missingObject",
424            "invalid format - expected 'object' line",
425        ));
426    }
427    i += 7;
428    let n = parse_oid_line(&data[i..], "badObjectSha1")?;
429    let tagged_oid = std::str::from_utf8(&data[i..i + 40])
430        .map_err(|_| FsckError::new("badObjectSha1", "invalid 'object' line format - bad sha1"))?
431        .parse::<ObjectId>()
432        .map_err(|_| FsckError::new("badObjectSha1", "invalid 'object' line format - bad sha1"))?;
433    i += n;
434
435    if i >= buffer_end || !data[i..].starts_with(b"type ") {
436        return Err(FsckError::new(
437            "missingTypeEntry",
438            "invalid format - expected 'type' line",
439        ));
440    }
441    i += 5;
442    let type_start = i;
443    let type_eol = data[type_start..buffer_end]
444        .iter()
445        .position(|&b| b == b'\n')
446        .map(|rel| type_start + rel)
447        .ok_or_else(|| {
448            FsckError::new(
449                "missingType",
450                "invalid format - unexpected end after 'type' line",
451            )
452        })?;
453
454    let tagged_kind = ObjectKind::from_tag_type_field(&data[type_start..type_eol])
455        .ok_or_else(|| FsckError::new("badType", "invalid 'type' value"))?;
456    i = type_eol + 1;
457
458    if i >= buffer_end || !data[i..].starts_with(b"tag ") {
459        return Err(FsckError::new(
460            "missingTagEntry",
461            "invalid format - expected 'tag' line",
462        ));
463    }
464    i += 4;
465    let tag_start = i;
466    let tag_eol = data[tag_start..buffer_end]
467        .iter()
468        .position(|&b| b == b'\n')
469        .map(|rel| tag_start + rel)
470        .ok_or_else(|| {
471            FsckError::new(
472                "missingTag",
473                "invalid format - unexpected end after 'type' line",
474            )
475        })?;
476
477    let tag_name = std::str::from_utf8(&data[tag_start..tag_eol])
478        .map_err(|_| FsckError::new("badTagName", "invalid 'tag' name"))?;
479    let refname = format!("refs/tags/{tag_name}");
480    if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
481        let e = FsckError::new("badTagName", format!("invalid 'tag' name: {tag_name}"));
482        if strict {
483            return Err(e);
484        }
485        on_warn(&e);
486    }
487    i = tag_eol + 1;
488
489    if i >= buffer_end {
490        let e = FsckError::new(
491            "missingTaggerEntry",
492            "invalid format - expected 'tagger' line",
493        );
494        if strict {
495            return Err(e);
496        }
497        on_warn(&e);
498        return Ok((tagged_oid, tagged_kind, i, true));
499    }
500
501    let tg_line_start = i;
502    let tg_eol = data[tg_line_start..buffer_end]
503        .iter()
504        .position(|&b| b == b'\n')
505        .map(|rel| tg_line_start + rel)
506        .ok_or_else(|| FsckError::new("unterminatedHeader", "unterminated header"))?;
507    let tg_line = &data[tg_line_start..tg_eol];
508
509    let missing_tagger = || {
510        FsckError::new(
511            "missingTaggerEntry",
512            "invalid format - expected 'tagger' line",
513        )
514    };
515
516    if tg_line == b"tagger" || !tg_line.starts_with(b"tagger ") {
517        let e = missing_tagger();
518        if strict {
519            return Err(e);
520        }
521        on_warn(&e);
522        i = tg_eol + 1;
523    } else {
524        i = tg_line_start + b"tagger ".len();
525        match fsck_ident(data, i, buffer_end, "author/committer") {
526            Ok(next) => {
527                i = next;
528                return Ok((tagged_oid, tagged_kind, i, true));
529            }
530            Err(e) => {
531                if strict {
532                    return Err(e);
533                }
534                on_warn(&e);
535                let tail = &data[tg_line_start..buffer_end];
536                i = if let Some(pos) = tail.windows(2).position(|w| w == b"\n\n") {
537                    tg_line_start + pos + 2
538                } else {
539                    buffer_end
540                };
541                return Ok((tagged_oid, tagged_kind, i, false));
542            }
543        }
544    }
545
546    Ok((tagged_oid, tagged_kind, i, true))
547}
548
549fn skip_tag_gpgsig_headers(data: &[u8], mut i: usize) -> Result<usize, FsckError> {
550    let buffer_end = data.len();
551    if i < buffer_end
552        && (data[i..].starts_with(b"gpgsig ") || data[i..].starts_with(b"gpgsig-sha256 "))
553    {
554        let sig_start = i;
555        let sig_eol = data[sig_start..buffer_end]
556            .iter()
557            .position(|&b| b == b'\n')
558            .map(|rel| sig_start + rel)
559            .ok_or_else(|| {
560                FsckError::new(
561                    "badGpgsig",
562                    "invalid format - unexpected end after 'gpgsig' or 'gpgsig-sha256' line",
563                )
564            })?;
565        i = sig_eol + 1;
566        while i < buffer_end && data[i] == b' ' {
567            let cont_eol = data[i..buffer_end]
568                .iter()
569                .position(|&b| b == b'\n')
570                .map(|rel| i + rel)
571                .ok_or_else(|| {
572                    FsckError::new(
573                        "badHeaderContinuation",
574                        "invalid format - unexpected end in 'gpgsig' or 'gpgsig-sha256' continuation line",
575                    )
576                })?;
577            i = cont_eol + 1;
578        }
579    }
580    Ok(i)
581}
582
583/// After `tagger` (or immediately after `tag` when tagger was omitted under `--no-strict`),
584/// validate optional `gpgsig` headers and the blank line before the body.
585pub fn fsck_tag_mktag_trailer_from(data: &[u8], start: usize) -> Result<(), FsckError> {
586    let buffer_end = data.len();
587    let i = skip_tag_gpgsig_headers(data, start)?;
588
589    if i < buffer_end && data[i] != b'\n' {
590        return Err(FsckError::new(
591            "extraHeaderEntry",
592            "invalid format - extra header(s) after 'tagger'",
593        ));
594    }
595
596    Ok(())
597}
598
599/// Trailing tag headers after `tagger` as enforced by `git mktag` / `fsck_tag_standalone`:
600/// optional `gpgsig` / `gpgsig-sha256` (+ continuations), then the blank line before the body.
601pub fn fsck_tag_mktag_trailer(data: &[u8]) -> Result<(), FsckError> {
602    let buffer_end = data.len();
603    let mut i = parse_tag_headers_through_tagger(data)?;
604
605    i = skip_tag_gpgsig_headers(data, i)?;
606
607    if i < buffer_end && data[i] != b'\n' {
608        return Err(FsckError::new(
609            "extraHeaderEntry",
610            "invalid format - extra header(s) after 'tagger'",
611        ));
612    }
613
614    Ok(())
615}
616
617fn fsck_tree(data: &[u8]) -> Result<(), FsckError> {
618    let mut pos = 0usize;
619    let mut names: Vec<Vec<u8>> = Vec::new();
620    while pos < data.len() {
621        let sp = data[pos..]
622            .iter()
623            .position(|&b| b == b' ')
624            .ok_or_else(|| FsckError::new("badTree", "cannot be parsed as a tree"))?;
625        let mode_bytes = &data[pos..pos + sp];
626        let mode = std::str::from_utf8(mode_bytes)
627            .ok()
628            .and_then(|s| u32::from_str_radix(s, 8).ok());
629        if !matches!(
630            mode,
631            Some(0o100644 | 0o100755 | 0o120000 | 0o040000 | 0o160000)
632        ) {
633            return Err(FsckError::new(
634                "badFilemode",
635                "malformed mode in tree entry",
636            ));
637        }
638        pos += sp + 1;
639
640        let nul = data[pos..]
641            .iter()
642            .position(|&b| b == 0)
643            .ok_or_else(|| FsckError::new("badTree", "cannot be parsed as a tree"))?;
644        if nul == 0 {
645            return Err(FsckError::new("emptyName", "empty filename in tree entry"));
646        }
647        names.push(data[pos..pos + nul].to_vec());
648        pos += nul + 1;
649
650        if pos + 20 > data.len() {
651            return Err(FsckError::new("badTree", "cannot be parsed as a tree"));
652        }
653        if ObjectId::from_bytes(&data[pos..pos + 20]).is_err() {
654            return Err(FsckError::new("badTree", "cannot be parsed as a tree"));
655        }
656        pos += 20;
657    }
658    let mut sorted = names;
659    sorted.sort();
660    if sorted.windows(2).any(|w| w[0] == w[1]) {
661        return Err(FsckError::new(
662            "duplicateEntries",
663            "duplicateEntries: contains duplicate file entries",
664        ));
665    }
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn empty_commit_is_unterminated_header() {
675        let e = fsck_object(ObjectKind::Commit, b"").unwrap_err();
676        assert_eq!(e.id, "unterminatedHeader");
677    }
678
679    #[test]
680    fn commit_missing_tree_matches_git() {
681        let e = fsck_object(ObjectKind::Commit, b"\n\n").unwrap_err();
682        assert_eq!(e.id, "missingTree");
683    }
684
685    #[test]
686    fn tree_truncated_is_bad_tree() {
687        let e = fsck_object(ObjectKind::Tree, b"100644 foo\0\x01\x01\x01\x01").unwrap_err();
688        assert_eq!(e.id, "badTree");
689    }
690}