git_policy_cli/subvolume/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 crate::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.
93pub(crate) struct Located {
94 /// Offset of the entry's first byte.
95 pub(crate) start: usize,
96 /// The path bytes.
97 pub(crate) path: Vec<u8>,
98 /// The entry's flags.
99 pub(crate) flags: u16,
100 /// The entry's mode.
101 pub(crate) mode: u32,
102}
103
104/// A big-endian `u32` at `at`, or nothing past the end.
105pub(crate) fn 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/// ```
126pub(crate) fn 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;