Skip to main content

git_policy_cli/
subvolume_index.rs

1//! What:
2//!  The stat rewrite of a Git index: the bytes of an index file in, the bytes of the same index out, with the stat
3//!  fields of each entry replaced by what the file on disk reports now.
4//! Why:
5//!  A reflink copy gives every file a new inode number and change time, so the first `git status` in the copied tree
6//!  finds every entry stat-dirty and hashes every file (22.1 s measured against 0.17 s with the rewrite, and the clean
7//!  filter of Git LFS runs on LFS files). The rewrite touches an entry only when the file's size and modification
8//!  time equal the entry's, so a file that really changed stays dirty. Entry lengths never change, so the entries are
9//!  edited in place. The accelerator extensions that record stat-derived state (`UNTR` untracked cache, `FSMN`
10//!  fsmonitor token) or offsets into the entries (`EOIE`, `IEOT`) are dropped, because Git rebuilds them; a split
11//!  index (`link`) or a sparse index (`sdir`) is left untouched. The checksum is recomputed with the hash that
12//!  verifies the old one (SHA-1, else SHA-256); an index written with `index.skipHash` has an all-zero checksum and
13//!  keeps one. Versions 2, 3 and 4 are read (`Documentation/gitformat-index.adoc`).
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! const outcome = rewriteIndexStat(bytes, (path) => lstatFields(join(tree, path))); // { bytes, updated, total } | { skipped }
18//! ```
19
20use super::subvolume_sha::{sha1, sha256};
21
22/// The bytes of the header: signature, version and entry count.
23const HEADER: usize = 12;
24
25/// The bytes of the fixed stat fields of an entry, before the object name.
26const STAT: usize = 40;
27
28/// The extension signatures that are dropped: derived state that Git rebuilds.
29const DROPPED: [&[u8; 4]; 4] = [b"UNTR", b"FSMN", b"EOIE", b"IEOT"];
30
31/// The extension signatures that make the rewrite decline: they change what the entries mean.
32const DECLINED: [&[u8; 4]; 2] = [b"link", b"sdir"];
33
34/// What:
35///  What a file on disk reports now, in the units an index entry stores.
36///  `u32` is an unsigned 32-bit integer (siblings `u64` and `i64`); an index stores each stat field as 32 bits.
37/// Why:
38///   The caller fills it from `lstat`; the rewrite compares size and modification time and copies the rest.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type StatFields = { ctimeSeconds: number; ctimeNanos: number; mtimeSeconds: number; mtimeNanos: number; dev: number; ino: number; uid: number; gid: number; size: number };
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub struct StatFields {
46    /// Change time, seconds.
47    pub ctime_seconds: u32,
48    /// Change time, nanoseconds.
49    pub ctime_nanos: u32,
50    /// Modification time, seconds.
51    pub mtime_seconds: u32,
52    /// Modification time, nanoseconds.
53    pub mtime_nanos: u32,
54    /// Device, low 32 bits.
55    pub dev: u32,
56    /// Inode, low 32 bits.
57    pub ino: u32,
58    /// Owner.
59    pub uid: u32,
60    /// Group.
61    pub gid: u32,
62    /// Size, low 32 bits.
63    pub size: u32,
64}
65
66/// What:
67///  The result of one rewrite.
68///  `Vec<u8>` is the owned bytes of the new file; `String` is the owned reason a rewrite was declined.
69/// Why:
70///   A declined index is left as copied, and the reason goes into the report, so the cost (a slow first status) is
71///  explained.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type IndexOutcome = { bytes: Uint8Array; updated: number; total: number } | { skipped: string };
76/// ```
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum IndexOutcome {
79    /// The new file; `updated` of `total` entries got fresh stat fields.
80    Rewritten {
81        /// The whole new index file, checksum included.
82        bytes: Vec<u8>,
83        /// Entries whose stat fields were replaced.
84        updated: usize,
85        /// Entries in the index.
86        total: usize,
87    },
88    /// The index was not changed, for this reason.
89    Skipped(String),
90}
91
92/// One entry found by the walk.
93struct Located {
94    /// Offset of the entry's first byte.
95    start: usize,
96    /// The path bytes.
97    path: Vec<u8>,
98    /// The entry's flags.
99    flags: u16,
100    /// The entry's mode.
101    mode: u32,
102}
103
104/// A big-endian `u32` at `at`, or nothing past the end.
105fn be32(bytes: &[u8], at: usize) -> Option<u32> {
106    let quad: &[u8] = bytes.get(at..at.checked_add(4)?)?;
107    return Some(u32::from_be_bytes([quad[0], quad[1], quad[2], quad[3]]));
108}
109
110/// A big-endian `u16` at `at`, or nothing past the end.
111fn be16(bytes: &[u8], at: usize) -> Option<u16> {
112    let pair: &[u8] = bytes.get(at..at.checked_add(2)?)?;
113    return Some(u16::from_be_bytes([pair[0], pair[1]]));
114}
115
116/// What:
117///  Walk the entries of an index whose checksum is `hash_length` bytes; return them and the offset where they end.
118/// Why:
119///   Entry layout depends on the hash length (the object name) and the version (v4 compresses the path against the
120///  previous one, v2 and v3 pad to 8 bytes).
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// function walkEntries(bytes: Uint8Array, hashLength: number): { entries: Located[]; end: number } | undefined;
125/// ```
126fn walk_entries(bytes: &[u8], hash_length: usize) -> Option<(Vec<Located>, usize)> {
127    let version: u32 = be32(bytes, 4)?;
128    let count: usize = usize::try_from(be32(bytes, 8)?).ok()?;
129    let limit: usize = bytes.len().checked_sub(hash_length)?;
130    let mut found: Vec<Located> = Vec::with_capacity(count.min(1 << 20));
131    let mut at: usize = HEADER;
132    let mut previous: Vec<u8> = Vec::new();
133    for _ in 0..count {
134        let mode: u32 = be32(bytes, at.checked_add(24)?)?;
135        let flags_at: usize = at.checked_add(STAT + hash_length)?;
136        let flags: u16 = be16(bytes, flags_at)?;
137        let mut name_at: usize = flags_at + 2;
138        if flags & 0x4000 != 0 {
139            if version < 3 {
140                return None;
141            }
142            name_at += 2;
143        }
144        let path: Vec<u8>;
145        let next: usize;
146        if version == 4 {
147            let mut cursor: usize = name_at;
148            let mut byte: u8 = *bytes.get(cursor)?;
149            cursor += 1;
150            let mut strip: usize = usize::from(byte & 0x7f);
151            while byte & 0x80 != 0 {
152                byte = *bytes.get(cursor)?;
153                cursor += 1;
154                strip = strip.checked_add(1)?.checked_shl(7)? | usize::from(byte & 0x7f);
155            }
156            let keep: usize = previous.len().checked_sub(strip)?;
157            let end: usize = cursor + bytes.get(cursor..limit)?.iter().position(|stored: &u8| { return *stored == 0; })?;
158            let mut joined: Vec<u8> = previous[..keep].to_vec();
159            joined.extend_from_slice(&bytes[cursor..end]);
160            path = joined;
161            next = end + 1;
162        } else {
163            let length: usize = bytes.get(name_at..limit)?.iter().position(|stored: &u8| { return *stored == 0; })?;
164            path = bytes[name_at..name_at + length].to_vec();
165            next = at.checked_add((name_at - at + length + 8) & !7)?;
166        }
167        if next > limit {
168            return None;
169        }
170        previous = path.clone();
171        found.push(Located { start: at, path, flags, mode });
172        at = next;
173    }
174    return Some((found, at));
175}
176
177/// What:
178///  Whether the extensions that follow the entries of an index whose checksum is `hash_length` bytes end exactly where
179///  the checksum begins.
180/// Why:
181///   An index with an all-zero checksum proves nothing about its hash length, so a length is accepted only when the
182///  whole file parses under it; a wrong length almost always leaves a cut-off extension.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// function extensionsFit(bytes: Uint8Array, entriesEnd: number, hashLength: number): boolean;
187/// ```
188fn extensions_fit(bytes: &[u8], entries_end: usize, hash_length: usize) -> bool {
189    let Some(limit) = bytes.len().checked_sub(hash_length) else { return false };
190    let mut at: usize = entries_end;
191    while at < limit {
192        let Some(size) = be32(bytes, at + 4) else { return false };
193        let Some(end) = at.checked_add(8).and_then(|header_end: usize| { return header_end.checked_add(usize::try_from(size).ok()?); }) else { return false };
194        if end > limit {
195            return false;
196        }
197        at = end;
198    }
199    return at == limit;
200}
201
202/// What:
203///  Which hash verifies the checksum, as a hash length: 20 for SHA-1, 32 for SHA-256, and for an all-zero checksum
204///  (`index.skipHash`) the one length under which the whole file parses.
205///  `known` is the repository's hash length when the caller read it from the repository's configuration.
206/// Why:
207///   A checksum that verifies proves its length. An all-zero checksum proves nothing, so the repository's object
208///  format decides when it is known; when it is not, a file that parses under both lengths is ambiguous and is
209///  declined, because a wrong guess drops or keeps the wrong bytes of every entry.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// function checksumLength(bytes: Uint8Array, known?: number): { length: number; zero: boolean } | { declined: string };
214/// ```
215fn checksum_kind(bytes: &[u8], known: Option<usize>) -> Result<(usize, bool), String> {
216    let candidates: Vec<usize> = match known {
217        Some(length) => vec![length],
218        None => vec![20, 32],
219    };
220    for length in &candidates {
221        let verifies: bool = match length {
222            20 => bytes.len() >= HEADER + 20 && sha1(&bytes[..bytes.len() - 20]) == bytes[bytes.len() - 20..],
223            32 => bytes.len() >= HEADER + 32 && sha256(&bytes[..bytes.len() - 32]) == bytes[bytes.len() - 32..],
224            _ => false,
225        };
226        if verifies {
227            return Ok((*length, false));
228        }
229    }
230    let fitting: Vec<usize> = candidates
231        .iter()
232        .copied()
233        .filter(|length: &usize| {
234            let zero: bool = bytes.len() >= HEADER + length && bytes[bytes.len() - length..].iter().all(|byte: &u8| { return *byte == 0; });
235            return zero && walk_entries(bytes, *length).is_some_and(|(_entries, end): (Vec<Located>, usize)| { return extensions_fit(bytes, end, *length); });
236        })
237        .collect();
238    return match fitting.as_slice() {
239        [length] => Ok((*length, true)),
240        [] if known.is_some() => Err(String::from("the checksum does not match the repository's hash and is not all zero")),
241        [] => Err(String::from("the checksum matches neither SHA-1 nor SHA-256, and it is not an all-zero checksum of a file that parses to its end")),
242        _ => Err(String::from("the checksum is all zero and the file parses as both SHA-1 and SHA-256, and the repository's object format is not known")),
243    };
244}
245
246/// What:
247///  Rewrite the stat fields of an index file; see the module description for the rules.
248///  `lookup` is called with an entry's path and returns what `lstat` reports now, or nothing for a missing file.
249///  `&mut dyn FnMut(&[u8]) -> Option<StatFields>` is a borrowed callback that may keep state (sibling `Fn` cannot).
250/// Why:
251///   Keeping the file system out of this function makes every branch testable with built bytes.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// function rewriteIndexStat(bytes: Uint8Array, lookup: (path: Uint8Array) => StatFields | undefined): IndexOutcome;
256/// ```
257pub fn rewrite_index_stat(bytes: &[u8], lookup: &mut dyn FnMut(&[u8]) -> Option<StatFields>) -> IndexOutcome {
258    return rewrite_index_stat_with(bytes, &RewriteContext::default(), lookup, &mut |_path: &[u8], _stored: &StoredStat| { return true; });
259}
260
261/// What:
262///  What the caller knows about the index beyond its bytes.
263///  `Option<usize>` is a value that may be absent.
264/// Why:
265///   The repository's hash length decides how an all-zero checksum is read, and the index file's own modification time
266///  decides which entries are racy.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// type RewriteContext = { hashLength?: number; indexMtime?: [number, number] };
271/// ```
272#[derive(Clone, Copy, Debug, Default)]
273pub struct RewriteContext {
274    /// The repository's hash length in bytes (20 or 32) when it was read from its configuration.
275    pub hash_length: Option<usize>,
276    /// The modification time of the index file the bytes came from: seconds and nanoseconds.
277    pub index_mtime: Option<(u32, u32)>,
278}
279
280/// What:
281///  The stat fields an entry holds, as stored.
282/// Why:
283///   The caller compares them with the file the index was written for, so an entry is refreshed only when that file is
284///  the one Git recorded and not one that changed since.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// type StoredStat = { ctimeSeconds: number; ctimeNanos: number; mtimeSeconds: number; mtimeNanos: number; ino: number; uid: number; gid: number; size: number };
289/// ```
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub struct StoredStat {
292    /// Change time, seconds.
293    pub ctime_seconds: u32,
294    /// Change time, nanoseconds (0 when Git was built without nanosecond support).
295    pub ctime_nanos: u32,
296    /// Modification time, seconds.
297    pub mtime_seconds: u32,
298    /// Modification time, nanoseconds (0 when Git was built without nanosecond support).
299    pub mtime_nanos: u32,
300    /// Inode, low 32 bits.
301    pub ino: u32,
302    /// Owner.
303    pub uid: u32,
304    /// Group.
305    pub gid: u32,
306    /// Size, low 32 bits.
307    pub size: u32,
308}
309
310/// What:
311///  Rewrite the stat fields of an index file; see the module description for the rules.
312///  `trusted` is asked, with an entry's path and stored fields, whether the file the entry was recorded for is still
313///  the one the copy was made from.
314///  An entry whose modification time is not earlier than the index file's own (racy in Git's terms) keeps its fields,
315///  so Git hashes it.
316/// Why:
317///   Replacing the change time and inode of an entry that Git could not trust, or of a file that changed after Git
318///  looked, would make Git believe the stored blob is current.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// function rewriteIndexStatWith(bytes: Uint8Array, context: RewriteContext, lookup: Lookup, trusted: (path: Uint8Array, stored: StoredStat) => boolean): IndexOutcome;
323/// ```
324pub fn rewrite_index_stat_with(
325    bytes: &[u8],
326    context: &RewriteContext,
327    lookup: &mut dyn FnMut(&[u8]) -> Option<StatFields>,
328    trusted: &mut dyn FnMut(&[u8], &StoredStat) -> bool,
329) -> IndexOutcome {
330    if bytes.len() < HEADER || &bytes[..4] != b"DIRC" {
331        return IndexOutcome::Skipped(String::from("the file is not an index (no DIRC signature)"));
332    }
333    let version: u32 = be32(bytes, 4).unwrap_or(0);
334    if !(2..=4).contains(&version) {
335        return IndexOutcome::Skipped(format!("index version {version} is not 2, 3 or 4"));
336    }
337    let (hash_length, zero_checksum): (usize, bool) = match checksum_kind(bytes, context.hash_length) {
338        Ok(found) => found,
339        Err(reason) => return IndexOutcome::Skipped(reason),
340    };
341    let Some((entries, entries_end)) = walk_entries(bytes, hash_length) else {
342        return IndexOutcome::Skipped(String::from("the entries do not have the documented layout"));
343    };
344    let limit: usize = bytes.len() - hash_length;
345    // The extensions after the entries: keep each unless it is derived state; decline on one that changes the meaning.
346    let mut kept: Vec<u8> = Vec::new();
347    let mut at: usize = entries_end;
348    while at < limit {
349        let (Some(size), Some(signature)) = (be32(bytes, at + 4), bytes.get(at..at + 4)) else {
350            return IndexOutcome::Skipped(String::from("an extension header is cut off"));
351        };
352        let end: usize = at + 8 + usize::try_from(size).unwrap_or(usize::MAX);
353        if end > limit {
354            return IndexOutcome::Skipped(String::from("an extension runs past the end of the file"));
355        }
356        if DECLINED.iter().any(|name: &&[u8; 4]| { return signature == name.as_slice(); }) {
357            return IndexOutcome::Skipped(format!("the index uses the {} extension (split or sparse index)", String::from_utf8_lossy(signature)));
358        }
359        if !DROPPED.iter().any(|name: &&[u8; 4]| { return signature == name.as_slice(); }) {
360            kept.extend_from_slice(&bytes[at..end]);
361        }
362        at = end;
363    }
364    let mut output: Vec<u8> = bytes[..entries_end].to_vec();
365    let mut updated: usize = 0;
366    for entry in &entries {
367        let regular: bool = matches!(entry.mode & 0o170_000, 0o100_000 | 0o120_000);
368        let resolved: bool = (entry.flags >> 12) & 3 == 0;
369        let stored_size: u32 = be32(bytes, entry.start + 36).unwrap_or(0);
370        if !regular || !resolved || stored_size == 0 {
371            continue;
372        }
373        let stored_seconds: u32 = be32(bytes, entry.start + 8).unwrap_or(0);
374        let stored_nanos: u32 = be32(bytes, entry.start + 12).unwrap_or(0);
375        // Git's racy test: an entry modified in the same second as the index file, or later, cannot be trusted by its
376        // stat fields alone. Its fields stay as recorded, which no longer match the copy, so Git hashes the file.
377        if context.index_mtime.is_some_and(|(index_seconds, _nanos): (u32, u32)| { return stored_seconds >= index_seconds; }) {
378            continue;
379        }
380        let Some(now) = lookup(entry.path.as_slice()) else { continue };
381        // An index written by a Git built without nanosecond support stores 0 there.
382        let same_time: bool = now.mtime_seconds == stored_seconds && (stored_nanos == 0 || stored_nanos == now.mtime_nanos);
383        if now.size != stored_size || !same_time {
384            continue;
385        }
386        let recorded: StoredStat = StoredStat {
387            ctime_seconds: be32(bytes, entry.start).unwrap_or(0),
388            ctime_nanos: be32(bytes, entry.start + 4).unwrap_or(0),
389            mtime_seconds: stored_seconds,
390            mtime_nanos: stored_nanos,
391            ino: be32(bytes, entry.start + 20).unwrap_or(0),
392            uid: be32(bytes, entry.start + 28).unwrap_or(0),
393            gid: be32(bytes, entry.start + 32).unwrap_or(0),
394            size: stored_size,
395        };
396        if !trusted(entry.path.as_slice(), &recorded) {
397            continue;
398        }
399        let fields: [(usize, u32); 6] = [(0, now.ctime_seconds), (4, now.ctime_nanos), (16, now.dev), (20, now.ino), (28, now.uid), (32, now.gid)];
400        for (offset, value) in fields {
401            output[entry.start + offset..entry.start + offset + 4].copy_from_slice(&value.to_be_bytes());
402        }
403        updated += 1;
404    }
405    output.extend_from_slice(kept.as_slice());
406    if zero_checksum {
407        output.extend(std::iter::repeat_n(0u8, hash_length));
408    } else if hash_length == 20 {
409        let digest: [u8; 20] = sha1(output.as_slice());
410        output.extend_from_slice(&digest);
411    } else {
412        let digest: [u8; 32] = sha256(output.as_slice());
413        output.extend_from_slice(&digest);
414    }
415    return IndexOutcome::Rewritten { bytes: output, updated, total: entries.len() };
416}
417
418/// Index rewrite controls stay out of the release executable.
419#[cfg(test)]
420#[path = "subvolume_index_tests.rs"]
421mod tests;