git_xcrypt/git/index.rs
1//! Reading git's index, and making git look at a file again after it was
2//! rewritten in place.
3//!
4//! Rewriting a working-tree file in place is not enough to leave `git status`
5//! clean, and the reason is a shortcut inside git. The index caches the `stat`
6//! of every file next to the object id of its **cleaned** content. When the
7//! cached size differs from the size on disk, git concludes the content changed
8//! and stops there — it never runs the clean filter to check. For an unfiltered
9//! file that shortcut is sound: a different size is a different file. For a
10//! filtered one it is not, and `unlock` hits it head on, because a clone checked
11//! out without a key recorded the size of the *ciphertext*, and the file is now
12//! its plaintext, 38 bytes shorter. `lock` hits the same wall going the other
13//! way.
14//!
15//! The object ids the index stores are read here too, by [`staged_ids`]. That is
16//! what lets `lock` answer "is this content already a blob in this repository"
17//! without opening the object database: the index records the id of every
18//! tracked path's *cleaned* content, and encryption is deterministic, so hashing
19//! what the clean path would produce and comparing is exact.
20//!
21//! **Paths are matched as raw bytes, and callers supply them from `read_dir`.**
22//! On a case-insensitive filesystem (`core.ignorecase`, the default on macOS and
23//! Windows) and under `core.precomposeunicode`, the same file has two spellings:
24//! git keeps the one it was added under, the directory keeps the one on disk.
25//! Measured on git 2.55/APFS — a file added as `secret.env` and renamed to
26//! `SECRET.env` reads as untracked here, and an NFD name on disk does not match
27//! the NFC name in the index. Both callers notice when a name they know is
28//! tracked does not match. **This gap survived open decision 13**, settled on
29//! 2026-08-05: pattern matching now folds ASCII case, so a *declaration* reaches
30//! every spelling of a name — but the question here is a different one, whether
31//! an index entry and a directory entry are the same file, and neither the index
32//! nor `read_dir` folds anything. What changed is which refusal fires first: the
33//! walk below now recognises `Secrets/db.env` as declared, so `lock` stops on
34//! "declared and not tracked under this name" rather than on "the index names a
35//! path this walk never saw". Same state, same exit code, and the message now
36//! names the spelling on disk, which is the one the user has to act on.
37//!
38//! **Correction, 2026-08-05: the consequences were not "on the safe side for
39//! `lock`, which then refuses rather than proceeds", as this comment claimed
40//! until now.** Measured on git 2.55 and APFS: after `mv secrets Secrets` the
41//! index still said `secrets/db.env`, `git status` was clean, the working-tree
42//! walk selected nothing, and `lock --yes` printed "no file here is declared for
43//! encryption", exited 0 and deleted the key over a readable plaintext secret —
44//! the interactive path did the same after a typed `yes`. `lock` now proves the
45//! opposite by content rather than assuming it: see
46//! `commands::lock::refuse_if_a_declared_file_is_still_open`, which reads this
47//! module's listing and refuses while any declared tracked path still holds
48//! plain text on disk.
49//!
50//! Measured on git 2.55, in a clone unlocked with the right key:
51//!
52//! ```text
53//! git hash-object --path secrets/db.env -- secrets/db.env → b51d5ac… (matches the index)
54//! git update-index --refresh → "needs update"
55//! git status --porcelain → " M secrets/db.env"
56//! ```
57//!
58//! The content is right, the blob is right, and git still reports a change —
59//! permanently, since the refresh never succeeds and so never rewrites the entry.
60//! Zeroing the cached size flips it: git's own comment in `read-cache.c` says
61//! that a zero length means "we have never even read the `lstat` information
62//! once", so it has to go to the filesystem and compare content. Measured, same
63//! repository: after zeroing, refresh exits 0 and `git status` is clean.
64//!
65//! So this module patches bytes rather than rebuilding the index: writing it out
66//! through a library would silently drop the extensions that library does not
67//! know how to write — the split-index link above all, whose loss is not a slow
68//! `git status` but a destroyed index. Patching in place preserves every byte we
69//! did not mean to change, and the trailing checksum is verified before the edit
70//! and recomputed after it, so a file that is not shaped the way we think is
71//! left alone rather than mangled.
72//!
73//! [`forget_stat`] touches four bytes per affected entry. [`restage`] also
74//! replaces the object id, and therefore has to drop the `TREE` cache — see its
75//! own comment for the measured reason, which is a commit that quietly stored
76//! the plaintext again.
77
78use std::fs;
79use std::path::Path;
80
81use crate::{Error, Result};
82
83/// `DIRC`, then the version and the entry count.
84const HEADER_LEN: usize = 12;
85
86/// What the index looked like, and what was done to it.
87#[derive(Debug, PartialEq, Eq)]
88pub enum Outcome {
89 /// The cached size was cleared for this many entries.
90 Cleared(usize),
91 /// Nothing was written, and why. Never a failure: the working tree is
92 /// already correct, so this costs a noisy `git status`, not data.
93 Skipped(String),
94}
95
96/// Makes git re-read `paths` by forgetting the size it cached for them.
97///
98/// `paths` are repository-relative and spelled with forward slashes, the way
99/// the index stores them.
100///
101/// # Errors
102///
103/// [`Error::Io`] when the index exists but cannot be read or replaced.
104pub fn forget_stat(index_path: &Path, hash: gix_hash::Kind, paths: &[Vec<u8>]) -> Result<Outcome> {
105 if paths.is_empty() {
106 return Ok(Outcome::Cleared(0));
107 }
108
109 // The lock comes before the read, not between the read and the write. Git's
110 // own protocol is lock-then-read for a reason: anything git writes to the
111 // index in the meantime — a `git add` in another terminal, an IDE refreshing
112 // in the background — would be silently reverted by our stale buffer, taking
113 // the staged changes with it.
114 let Some(lock) = Lock::acquire(index_path)? else {
115 return Ok(Outcome::Skipped(format!(
116 "{}.lock is held by another git process, so the stat cache was left \
117 alone. The files are decrypted correctly; if `git status` shows them \
118 as modified, `git add --renormalize .` settles it.",
119 index_path.display()
120 )));
121 };
122
123 let data = match fs::read(index_path) {
124 Ok(data) => data,
125 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
126 return Ok(Outcome::Skipped(format!(
127 "{} does not exist, so there is no stat cache to refresh",
128 index_path.display()
129 )));
130 }
131 Err(err) => return Err(Error::Io(err)),
132 };
133
134 let hash_len = hash.len_in_bytes();
135 let Index {
136 mut data,
137 body_len,
138 version,
139 count,
140 skip_hash,
141 } = match inspect(data, hash) {
142 Ok(index) => index,
143 Err(why) => return Ok(skipped(index_path, &why)),
144 };
145
146 let Some(scan) = scan(&data[..body_len], version, count, hash_len, paths) else {
147 return Ok(skipped(index_path, "its entries did not parse"));
148 };
149 if scan.split_index {
150 // The entries live in `.git/sharedindex.<oid>` and this file holds only
151 // the differences, so there is nothing here to patch. Measured on git
152 // 2.55 with `core.splitIndex=true`: without this branch the walk matched
153 // nothing, reported success and left `git status` permanently dirty —
154 // the exact failure this module exists to prevent, arriving silently.
155 // `features.manyFiles=true` turns split index on wholesale.
156 return Ok(skipped(
157 index_path,
158 "this repository uses a split index, whose entries live in a shared \
159 file this build does not patch",
160 ));
161 }
162 if scan.size_fields.is_empty() {
163 // None of the rewritten files is tracked — an encrypted file a user
164 // keeps in the working tree without committing it, for instance. There
165 // is no cached stat to forget.
166 return Ok(Outcome::Cleared(0));
167 }
168
169 for offset in &scan.size_fields {
170 data[*offset..*offset + 4].fill(0);
171 }
172 if !skip_hash {
173 let Some(digest) = checksum(&data[..body_len], hash) else {
174 return Ok(skipped(index_path, "its checksum could not be computed"));
175 };
176 data[body_len..].copy_from_slice(&digest);
177 }
178
179 lock.commit(&data)?;
180 Ok(Outcome::Cleared(scan.size_fields.len()))
181}
182
183/// Points index entries at different blobs, and forgets their cached size.
184///
185/// This is what `status --fix` is: git's own `git add` on a path whose staged
186/// content is plain text, done without spawning git. The blob has to exist in
187/// the object database already — the caller writes it — and this puts the index
188/// entry on it, so the next commit stores the ciphertext.
189///
190/// The stat cache is cleared in the same pass, and not as a nicety: with the
191/// old size still recorded, git compares it against the working-tree file,
192/// concludes the content changed and never runs the clean filter to find out
193/// otherwise. See this module's opening comment.
194///
195/// Stage 0 only. A path in the middle of a merge has no settled content, and
196/// rewriting one side of a conflict to point at a blob nobody asked for would be
197/// the worst kind of help.
198///
199/// Reports the paths it actually repointed, not how many. A count cannot say
200/// *which*, and the difference is not cosmetic: a path the index spells
201/// differently than the directory does — case folding on macOS and Windows, NFD
202/// against NFC — is silently not found, and a caller left to subtract counts
203/// would name the wrong file as fixed while the real one vanished from the
204/// "still in the clear" list.
205///
206/// # Errors
207///
208/// [`Error::Io`] when the index cannot be read or replaced. [`Error::Config`]
209/// when an object id is not the length this repository's hash produces —
210/// writing a short id into an entry would corrupt every entry after it.
211pub fn restage(
212 index_path: &Path,
213 hash: gix_hash::Kind,
214 updates: &[(Vec<u8>, Vec<u8>)],
215) -> Result<Restaged> {
216 let hash_len = hash.len_in_bytes();
217 if updates.is_empty() {
218 return Ok(Restaged::Done(Vec::new()));
219 }
220 for (path, id) in updates {
221 if id.len() != hash_len {
222 return Err(Error::Config(format!(
223 "{}: the new object id is {} bytes, but this repository's index \
224 stores {hash_len}; the index was left alone",
225 String::from_utf8_lossy(path),
226 id.len()
227 )));
228 }
229 }
230
231 // Lock first, then read: the same order git uses, and for the same reason —
232 // a `git add` in another terminal between our read and our write would be
233 // silently reverted by a stale buffer, taking its staged changes with it.
234 let Some(lock) = Lock::acquire(index_path)? else {
235 return Ok(Restaged::Skipped(format!(
236 "{}.lock is held by another git process, so nothing was re-staged. \
237 Try again, or run `git add` on the reported paths yourself.",
238 index_path.display()
239 )));
240 };
241
242 let data = match fs::read(index_path) {
243 Ok(data) => data,
244 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
245 return Ok(Restaged::Skipped(format!(
246 "{} does not exist, so there is nothing staged to re-stage",
247 index_path.display()
248 )));
249 }
250 Err(err) => return Err(Error::Io(err)),
251 };
252
253 let Index {
254 mut data,
255 body_len,
256 version,
257 count,
258 skip_hash,
259 } = match inspect(data, hash) {
260 Ok(index) => index,
261 Err(why) => return Ok(Restaged::Skipped(why_skipped(index_path, &why))),
262 };
263
264 // The name comes back with the offset, so what was patched is known rather
265 // than inferred from a count.
266 let mut edits: Vec<(usize, &[u8], Vec<u8>)> = Vec::new();
267 let walked = walk(&data[..body_len], version, count, hash_len, &mut |entry| {
268 if entry.stage != 0 {
269 return;
270 }
271 if let Some((path, id)) = updates.iter().find(|(path, _)| path == entry.name) {
272 edits.push((entry.start, id.as_slice(), path.clone()));
273 }
274 });
275
276 let layout = match walked {
277 None => {
278 return Ok(Restaged::Skipped(why_skipped(
279 index_path,
280 "its entries did not parse",
281 )));
282 }
283 Some(walked) if walked.split_index => {
284 return Ok(Restaged::Skipped(why_skipped(
285 index_path,
286 "this repository uses a split index, whose entries live in a shared \
287 file this build does not patch",
288 )));
289 }
290 Some(walked) => walked,
291 };
292 if edits.is_empty() {
293 return Ok(Restaged::Done(Vec::new()));
294 }
295
296 let mut patched = Vec::with_capacity(edits.len());
297 for (start, id, path) in edits {
298 data[start + ID_FIELD..start + ID_FIELD + hash_len].copy_from_slice(id);
299 data[start + SIZE_FIELD..start + SIZE_FIELD + 4].fill(0);
300 patched.push(path);
301 }
302
303 // **The cache tree has to go, and this is not housekeeping.** `TREE` caches
304 // the tree object each directory would write to, and git trusts it: measured
305 // on git 2.55, an index whose entry was repointed at a new blob while `TREE`
306 // still named the old directory tree left `git diff-index --cached HEAD`
307 // reporting *no change at all*, and the next `git commit` wrote the stale
308 // tree — so the plaintext went back into the object database from a command
309 // that had just reported it fixed. `git add` avoids this by invalidating the
310 // path's ancestors; dropping the extension is the same thing with a wider
311 // brush, and costs one rebuild on the next commit.
312 //
313 // `EOIE` goes with it because it carries a hash over the extension headers,
314 // which removing one invalidates. Everything else is kept: `IEOT` indexes
315 // the *entries*, whose lengths are unchanged, and `REUC`, `UNTR` and the
316 // fsmonitor state describe things this edit did not touch.
317 let mut rebuilt = data[..layout.extensions_at].to_vec();
318 for extension in &layout.extensions {
319 if matches!(&extension.signature, b"TREE" | b"EOIE") {
320 continue;
321 }
322 rebuilt.extend_from_slice(&data[extension.start..extension.end]);
323 }
324
325 if skip_hash {
326 rebuilt.extend_from_slice(&vec![0u8; hash_len]);
327 } else {
328 let Some(digest) = checksum(&rebuilt, hash) else {
329 return Ok(Restaged::Skipped(why_skipped(
330 index_path,
331 "its checksum could not be computed",
332 )));
333 };
334 rebuilt.extend_from_slice(&digest);
335 }
336 debug_assert!(body_len >= layout.extensions_at);
337
338 lock.commit(&rebuilt)?;
339 Ok(Restaged::Done(patched))
340}
341
342/// What [`restage`] did.
343#[derive(Debug, PartialEq, Eq)]
344pub enum Restaged {
345 /// The paths whose entries were repointed, in the order the index stores
346 /// them. A path the caller asked about and that is missing here was **not**
347 /// re-staged, whatever the reason.
348 Done(Vec<Vec<u8>>),
349 /// Nothing was written, and why.
350 Skipped(String),
351}
352
353/// What the index says about a set of paths.
354#[derive(Debug, PartialEq, Eq)]
355pub enum Staged {
356 /// The object id the index records for each requested path, in the order
357 /// they were asked for. `None` where the index has no stage-0 entry for it,
358 /// which covers an untracked path and an unresolved conflict alike — both
359 /// mean "this path's content is not simply stored here".
360 Read(Vec<Option<Vec<u8>>>),
361 /// The index could not be read, and why.
362 ///
363 /// A separate answer from "no entry", because the two must not be confused
364 /// by a caller that refuses on the second: an unreadable index is not
365 /// evidence that anything is unstored.
366 Unavailable(String),
367}
368
369/// The object ids the index records for `paths`.
370///
371/// No lock is taken: git replaces the index by renaming a complete file over
372/// it, so a reader sees one version or the other and never a half-written one.
373/// [`forget_stat`] locks because it writes.
374///
375/// # Errors
376///
377/// [`Error::Io`] when the index exists but cannot be read. An index that does
378/// not exist yet is not an error — nothing is tracked, so every answer is
379/// `None`.
380pub fn staged_ids(index_path: &Path, hash: gix_hash::Kind, paths: &[Vec<u8>]) -> Result<Staged> {
381 let data = match fs::read(index_path) {
382 Ok(data) => data,
383 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
384 return Ok(Staged::Read(vec![None; paths.len()]));
385 }
386 Err(err) => return Err(Error::Io(err)),
387 };
388
389 let index = match inspect(data, hash) {
390 Ok(index) => index,
391 Err(why) => return Ok(Staged::Unavailable(why)),
392 };
393
394 let mut found: Vec<Option<Vec<u8>>> = vec![None; paths.len()];
395 let body = &index.data[..index.body_len];
396 let walked = walk(
397 body,
398 index.version,
399 index.count,
400 hash.len_in_bytes(),
401 &mut |entry| {
402 // Stage 0 only. A path in the middle of a merge has entries at
403 // stages 1 to 3 and no settled content at all, which has to read as
404 // "not stored" rather than as whichever side happened to come last.
405 if entry.stage != 0 {
406 return;
407 }
408 // Every matching position, not the first: a caller is allowed to ask
409 // about the same path twice, and answering only one of them would
410 // leave the other reading as "not stored" — which for `lock` is the
411 // difference between a file it keeps and a file it deletes.
412 for (at, path) in paths.iter().enumerate() {
413 if path.as_slice() == entry.name {
414 found[at] = Some(entry.id.to_vec());
415 }
416 }
417 },
418 );
419
420 // `found` is published only on a complete walk. `visit` runs per entry, so a
421 // walk that gives up half way has already filled part of it — and a partial
422 // answer here would be a truthful-looking `Some(id)` beside a `None` that
423 // only means "the parse stopped before reaching it", which is the value that
424 // decides whether `lock` deletes a file.
425 match walked {
426 None => Ok(Staged::Unavailable("its entries did not parse".into())),
427 Some(walked) if walked.split_index => Ok(Staged::Unavailable(
428 "this repository uses a split index, whose entries live in a shared \
429 file this build does not read"
430 .into(),
431 )),
432 Some(_) => Ok(Staged::Read(found)),
433 }
434}
435
436/// Every stage-0 entry in the index, or why it could not be read.
437///
438/// The same distinction [`Staged`] draws, and for the same reason: an index this
439/// build cannot parse is not evidence that nothing is tracked.
440#[derive(Debug, PartialEq, Eq)]
441pub enum Listed {
442 /// Every stage-0 entry, in the order the index stores them.
443 Read(Vec<Tracked>),
444 /// The index could not be read, and why.
445 Unavailable(String),
446}
447
448/// One tracked path, as the index records it.
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct Tracked {
451 /// The path, spelled exactly as the index spells it.
452 pub path: Vec<u8>,
453 /// Object id of its cleaned content.
454 pub id: Vec<u8>,
455 /// The entry mode: `0o100644`, `0o100755`, `0o120000` for a symbolic link,
456 /// `0o160000` for a submodule.
457 ///
458 /// Carried rather than dropped, and the reason is not tidiness. Measured on
459 /// the build before it was: `status --fix` read a tracked **symlink** as
460 /// ordinary content — a symlink's blob is its target string, which carries
461 /// no magic — followed it with `fs::read`, encrypted whatever it pointed at
462 /// and repointed the entry, leaving the mode at `0o120000`. The next clone
463 /// got a symlink whose target was the first NUL of a ciphertext, and the
464 /// plaintext of a file no pattern declared was now a blob in the object
465 /// database. The history scan had the check all along and the two disagreed.
466 pub mode: u32,
467 /// Whether this is a `git add -N` placeholder rather than staged content.
468 ///
469 /// Such an entry carries mode `100644` and the empty blob, so it reads as
470 /// "stored in the clear" and `--fix` used to repoint it — announcing a
471 /// repair that the next `git commit` did not make, because git still treats
472 /// the path as unstaged.
473 pub intent_to_add: bool,
474}
475
476impl Tracked {
477 /// Whether this entry is a regular file, the only kind git filters.
478 ///
479 /// A symbolic link and a submodule gitlink both hold something that is not
480 /// file content, so no declaration could ever have applied to them.
481 #[must_use]
482 pub fn is_regular_file(&self) -> bool {
483 self.mode & 0o170_000 == 0o100_000
484 }
485
486 /// Whether this entry holds content the next commit would actually store.
487 #[must_use]
488 pub fn holds_content(&self) -> bool {
489 self.is_regular_file() && !self.intent_to_add
490 }
491}
492
493/// Lists what the index records, without being told the paths in advance.
494///
495/// [`staged_ids`] answers about paths a caller already knows; `status` needs the
496/// other direction — "which declared paths would the next commit store, and as
497/// what" — and can only get there by enumerating. Both go through the one parser
498/// in this module, so there is never a second reading of the same bytes.
499///
500/// # Errors
501///
502/// [`Error::Io`] when the index exists but cannot be read. An index that does
503/// not exist yet is not an error: nothing is tracked, so the list is empty.
504pub fn list(index_path: &Path, hash: gix_hash::Kind) -> Result<Listed> {
505 let data = match fs::read(index_path) {
506 Ok(data) => data,
507 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
508 return Ok(Listed::Read(Vec::new()));
509 }
510 Err(err) => return Err(Error::Io(err)),
511 };
512
513 let index = match inspect(data, hash) {
514 Ok(index) => index,
515 Err(why) => return Ok(Listed::Unavailable(why)),
516 };
517
518 let mut entries = Vec::with_capacity(index.count);
519 let walked = walk(
520 &index.data[..index.body_len],
521 index.version,
522 index.count,
523 hash.len_in_bytes(),
524 &mut |entry| {
525 // Stage 0 only, as in `staged_ids`: a path mid-merge has no settled
526 // content, and reporting whichever side came last as "what this
527 // repository stores" would be a guess presented as a fact.
528 if entry.stage == 0 {
529 entries.push(Tracked {
530 path: entry.name.to_vec(),
531 id: entry.id.to_vec(),
532 mode: entry.mode,
533 intent_to_add: entry.intent_to_add,
534 });
535 }
536 },
537 );
538
539 // Published only on a complete walk, exactly as `staged_ids` does: a partial
540 // list reads as "these are the tracked paths" while quietly omitting the
541 // rest, and here the omission would be a declared path reported as safe.
542 match walked {
543 None => Ok(Listed::Unavailable("its entries did not parse".into())),
544 Some(walked) if walked.split_index => Ok(Listed::Unavailable(
545 "this repository uses a split index, whose entries live in a shared \
546 file this build does not read"
547 .into(),
548 )),
549 Some(_) => Ok(Listed::Read(entries)),
550 }
551}
552
553/// The object id git stores for `content` as a blob.
554///
555/// Git hashes `blob <length>\0` followed by the bytes. Deterministic encryption
556/// is what makes this useful: hashing the ciphertext the clean path would
557/// produce answers "is this working-tree file already stored" exactly, without
558/// opening a single object.
559#[must_use]
560pub fn blob_id(hash: gix_hash::Kind, content: &[u8]) -> Option<Vec<u8>> {
561 let mut hasher = gix_hash::hasher(hash);
562 hasher.update(format!("blob {}\0", content.len()).as_bytes());
563 hasher.update(content);
564 hasher
565 .try_finalize()
566 .ok()
567 .map(|digest| digest.as_slice().to_vec())
568}
569
570/// An index this build is willing to act on.
571struct Index {
572 data: Vec<u8>,
573 /// Everything before the trailing checksum.
574 body_len: usize,
575 version: u32,
576 count: usize,
577 /// The checksum was zeroed, as `index.skipHash` does, and must stay so.
578 skip_hash: bool,
579}
580
581/// Validates the fixed parts of an index, or says why it cannot be used.
582///
583/// Shared by the reader and the writer so the two can never disagree about
584/// which files they understand.
585fn inspect(data: Vec<u8>, hash: gix_hash::Kind) -> std::result::Result<Index, String> {
586 let hash_len = hash.len_in_bytes();
587 if data.len() < HEADER_LEN + hash_len || !data.starts_with(b"DIRC") {
588 return Err("it is not an index this build can read".into());
589 }
590
591 let body_len = data.len() - hash_len;
592 let recorded = &data[body_len..];
593 // `index.skipHash` writes zeroes here and tells git not to verify. Keeping
594 // that promise means writing zeroes back rather than filling it in. A tail
595 // zeroed by a bad write rather than by that setting is not covered by this
596 // check, but is by the structural one in `walk`: the entry and extension
597 // walk has to land exactly on the end of the data or nothing is written.
598 let skip_hash = recorded.iter().all(|byte| *byte == 0);
599 if !skip_hash {
600 let Some(digest) = checksum(&data[..body_len], hash) else {
601 return Err("its checksum could not be computed".into());
602 };
603 if digest != recorded {
604 return Err("its checksum does not match its contents".into());
605 }
606 }
607
608 let version = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
609 let count = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
610 if !(2..=4).contains(&version) {
611 return Err(format!(
612 "it is version {version}, which this build does not know"
613 ));
614 }
615
616 Ok(Index {
617 data,
618 body_len,
619 version,
620 count,
621 skip_hash,
622 })
623}
624
625/// The usual shape of a refusal, with an instruction the user can act on.
626fn skipped(index_path: &Path, why: &str) -> Outcome {
627 Outcome::Skipped(format!(
628 "{} was left alone because {why}. The files are decrypted correctly; if \
629 `git status` shows them as modified, `git add --renormalize .` settles it.",
630 index_path.display()
631 ))
632}
633
634/// The same refusal for [`restage`], whose caller has a different repair.
635///
636/// `git add --renormalize` is the wrong advice here: nothing was rewritten in
637/// the working tree, so what the user needs is to stage the paths themselves.
638fn why_skipped(index_path: &Path, why: &str) -> String {
639 format!(
640 "{} was left alone because {why}, so nothing was re-staged. \
641 `git add` on the reported paths does the same job.",
642 index_path.display()
643 )
644}
645
646/// The index checksum over `body`.
647fn checksum(body: &[u8], hash: gix_hash::Kind) -> Option<Vec<u8>> {
648 let mut hasher = gix_hash::hasher(hash);
649 hasher.update(body);
650 hasher
651 .try_finalize()
652 .ok()
653 .map(|digest| digest.as_slice().to_vec())
654}
655
656/// What one pass over the index found.
657#[derive(Debug)]
658struct Scan {
659 /// Offsets of the `size` field of every entry naming one of `paths`.
660 size_fields: Vec<usize>,
661 /// The index carries a `link` extension, so its entries are elsewhere.
662 split_index: bool,
663}
664
665/// Offset of the `size` field from the start of an entry.
666const SIZE_FIELD: usize = 36;
667
668/// Offset of the object id from the start of an entry, after the stat block.
669const ID_FIELD: usize = 40;
670
671/// One index entry, as [`walk`] hands it over.
672struct Entry<'a> {
673 /// Offset of the entry from the start of the index.
674 start: usize,
675 /// The path, spelled exactly as the index spells it.
676 name: &'a [u8],
677 /// Object id of the entry's cleaned content.
678 id: &'a [u8],
679 /// Merge stage; anything but 0 is an unresolved conflict.
680 stage: u8,
681 /// The entry mode, which says whether this is a file at all.
682 mode: u32,
683 /// `git add -N`: the path is announced but its content is not staged.
684 intent_to_add: bool,
685}
686
687/// Offset of the `mode` field from the start of an entry.
688///
689/// After `ctime` (8), `mtime` (8), `dev` (4) and `ino` (4).
690const MODE_FIELD: usize = 24;
691
692/// Finds the entries the caller asked about, if the whole index parses.
693///
694/// No stage filter, unlike [`staged_ids`], and the asymmetry is deliberate: this
695/// one only zeroes a cached `stat`, and a conflicted entry carries a zeroed one
696/// already, so clearing it changes nothing git will act on. Verified against git
697/// 2.55 on a conflicted index in versions 2, 3 and 4 — the merge still resolved.
698/// The only visible effect is that [`Outcome::Cleared`] counts the extra stages.
699fn scan(
700 body: &[u8],
701 version: u32,
702 count: usize,
703 hash_len: usize,
704 paths: &[Vec<u8>],
705) -> Option<Scan> {
706 let mut fields = Vec::new();
707 let walked = walk(body, version, count, hash_len, &mut |entry| {
708 if paths.iter().any(|path| path.as_slice() == entry.name) {
709 fields.push(entry.start + SIZE_FIELD);
710 }
711 })?;
712
713 Some(Scan {
714 size_fields: fields,
715 split_index: walked.split_index,
716 })
717}
718
719/// Walks the entries and then the extensions, or gives up entirely.
720///
721/// Returns `None` for anything that does not parse exactly, which is what keeps
722/// a misread from turning into a patched byte in the wrong place. The extension
723/// walk is not only there to spot a split index: it has to consume the file to
724/// its last byte, which is what proves the entry walk ended where it should
725/// rather than somewhere plausible. The extensions are located but not decoded,
726/// which is enough for the two questions callers have: whether a `link`
727/// extension makes this a split index, and which bytes an edit has to leave out
728/// when a cache it invalidates has to go.
729///
730/// The entry layout is identical in every index version: `ctime` (8), `mtime`
731/// (8), `dev`, `ino`, `mode`, `uid`, `gid`, `size` (4 each), the object id, then
732/// a 16-bit flags word. Only the name differs — versions 2 and 3 store it
733/// NUL-terminated and pad the entry to a multiple of eight, version 4 stores it
734/// as "strip this many bytes off the previous name, then append this" with no
735/// padding at all.
736///
737/// `visit` is called once per entry, in file order. It is a callback rather than
738/// a returned list because two callers want different fields out of the same
739/// walk, and a second copy of this parser is the last thing this module needs.
740fn walk(
741 body: &[u8],
742 version: u32,
743 count: usize,
744 hash_len: usize,
745 visit: &mut dyn FnMut(&Entry<'_>),
746) -> Option<Walked> {
747 // Everything before the name: the stat block, the object id, the flags.
748 let fixed = ID_FIELD + hash_len + 2;
749
750 let mut cursor = HEADER_LEN;
751 let mut previous: Vec<u8> = Vec::new();
752
753 for _ in 0..count {
754 let start = cursor;
755 let flags_at = start.checked_add(ID_FIELD + hash_len)?;
756 if body.len() < flags_at + 2 {
757 return None;
758 }
759 let flags = u16::from_be_bytes([body[flags_at], body[flags_at + 1]]);
760 let extended = flags & 0x4000 != 0;
761 // The on-disk extended word carries git's bits 16..31, so its `0x2000`
762 // is `CE_INTENT_TO_ADD (1 << 29)`.
763 let intent_to_add = version >= 3
764 && extended
765 && body.len() >= flags_at + 4
766 && u16::from_be_bytes([body[flags_at + 2], body[flags_at + 3]]) & 0x2000 != 0;
767 let stage = ((flags >> 12) & 0x3) as u8;
768 let declared = usize::from(flags & 0x0fff);
769
770 let mut at = start + fixed;
771 if version >= 3 && extended {
772 at += 2;
773 }
774 if at > body.len() {
775 return None;
776 }
777
778 let name = if version < 4 {
779 // A declared length of 0xfff means "longer than this field can
780 // say"; only then is the NUL the sole authority.
781 let end = if declared < 0x0fff {
782 let end = at.checked_add(declared)?;
783 if body.len() <= end || body[end] != 0 {
784 return None;
785 }
786 end
787 } else {
788 at + body[at..].iter().position(|byte| *byte == 0)?
789 };
790 // Git pads each entry to a multiple of eight, always leaving at
791 // least one NUL after the name.
792 cursor = start + (((end - start) + 8) & !7);
793 body[at..end].to_vec()
794 } else {
795 let (strip, used) = varint(body.get(at..)?)?;
796 let suffix_at = at + used;
797 let end = suffix_at + body.get(suffix_at..)?.iter().position(|byte| *byte == 0)?;
798 if strip > previous.len() {
799 return None;
800 }
801 cursor = end + 1;
802 let mut name = previous[..previous.len() - strip].to_vec();
803 name.extend_from_slice(&body[suffix_at..end]);
804 name
805 };
806
807 if cursor > body.len() {
808 return None;
809 }
810 visit(&Entry {
811 start,
812 name: &name,
813 id: &body[start + ID_FIELD..flags_at],
814 stage,
815 mode: u32::from_be_bytes([
816 body[start + MODE_FIELD],
817 body[start + MODE_FIELD + 1],
818 body[start + MODE_FIELD + 2],
819 body[start + MODE_FIELD + 3],
820 ]),
821 intent_to_add,
822 });
823 previous = name;
824 }
825
826 // The extension section: a four-byte signature and a length each, back to
827 // back, until the data runs out. Walking it has to land exactly on the last
828 // byte — anything else means the entry walk went wrong somewhere earlier and
829 // the offsets above are not `size` fields at all.
830 let mut walked = Walked {
831 split_index: false,
832 extensions_at: cursor,
833 extensions: Vec::new(),
834 };
835 while cursor < body.len() {
836 let header_end = cursor.checked_add(8)?;
837 if header_end > body.len() {
838 return None;
839 }
840 let mut signature = [0u8; 4];
841 signature.copy_from_slice(&body[cursor..cursor + 4]);
842 if &signature == b"link" {
843 walked.split_index = true;
844 }
845 let length = u32::from_be_bytes([
846 body[cursor + 4],
847 body[cursor + 5],
848 body[cursor + 6],
849 body[cursor + 7],
850 ]) as usize;
851 let start = cursor;
852 cursor = header_end.checked_add(length)?;
853 if cursor > body.len() {
854 return None;
855 }
856 walked.extensions.push(Extension {
857 signature,
858 start,
859 end: cursor,
860 });
861 }
862
863 Some(walked)
864}
865
866/// What one pass over the whole index learned about its layout.
867struct Walked {
868 /// The index carries a `link` extension, so its entries are elsewhere.
869 split_index: bool,
870 /// Where the entries stop and the extensions begin.
871 extensions_at: usize,
872 /// Every extension, in file order.
873 extensions: Vec<Extension>,
874}
875
876/// One extension, located rather than decoded.
877struct Extension {
878 signature: [u8; 4],
879 start: usize,
880 end: usize,
881}
882
883/// Git's variable-width integer, as version 4 uses it for the prefix length.
884///
885/// Returns the value and how many bytes it took. A port of git's
886/// `decode_varint`, which is not the usual LEB128: each continuation adds one
887/// before shifting, so no value has two encodings.
888fn varint(bytes: &[u8]) -> Option<(usize, usize)> {
889 let mut index = 1;
890 let mut byte = *bytes.first()?;
891 let mut value = usize::from(byte & 0x7f);
892
893 while byte & 0x80 != 0 {
894 // Ten bytes is already far past any plausible path length; the bound
895 // is here so a corrupt index cannot spin.
896 if index >= 10 {
897 return None;
898 }
899 value = value.checked_add(1)?;
900 byte = *bytes.get(index)?;
901 index += 1;
902 value = value
903 .checked_mul(128)?
904 .checked_add(usize::from(byte & 0x7f))?;
905 }
906
907 Some((value, index))
908}
909
910/// `index.lock`, held for the whole read-modify-write.
911///
912/// Using git's own lock name rather than a private temporary file is what makes
913/// this safe next to a concurrent git: whoever creates the lock first wins, and
914/// the other backs off. Dropping the guard without committing removes the lock,
915/// so every early return in [`forget_stat`] releases it.
916struct Lock {
917 path: std::path::PathBuf,
918 target: std::path::PathBuf,
919 file: Option<fs::File>,
920}
921
922impl Lock {
923 /// Takes the lock, or reports that someone else has it.
924 ///
925 /// # Errors
926 ///
927 /// [`Error::Io`] when the lock file cannot be created for any other reason.
928 fn acquire(index_path: &Path) -> Result<Option<Self>> {
929 let path = index_path.with_extension("lock");
930 match fs::OpenOptions::new()
931 .write(true)
932 .create_new(true)
933 .open(&path)
934 {
935 Ok(file) => Ok(Some(Self {
936 path,
937 target: index_path.to_path_buf(),
938 file: Some(file),
939 })),
940 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
941 Err(err) => Err(Error::Io(err)),
942 }
943 }
944
945 /// Writes `data` and renames the lock into place.
946 ///
947 /// # Errors
948 ///
949 /// [`Error::Io`] when the write or the rename fails; the index is then left
950 /// exactly as it was and the lock is released.
951 fn commit(mut self, data: &[u8]) -> Result<()> {
952 use std::io::Write as _;
953
954 let mut file = self.file.take().ok_or_else(|| {
955 Error::Io(std::io::Error::other("the index lock was already released"))
956 })?;
957
958 let result = (|| -> std::io::Result<()> {
959 if let Ok(existing) = fs::metadata(&self.target) {
960 file.set_permissions(existing.permissions())?;
961 }
962 file.write_all(data)?;
963 file.sync_all()?;
964 Ok(())
965 })();
966 drop(file);
967
968 if let Err(err) = result.and_then(|()| fs::rename(&self.path, &self.target)) {
969 let _ = fs::remove_file(&self.path);
970 return Err(Error::Io(err));
971 }
972
973 // Same best-effort flush `atomic::write` does after its rename, for the
974 // same reason and with a smaller consequence: a crash here costs a stale
975 // stat cache, not a missing file.
976 if let Some(parent) = self.target.parent()
977 && let Ok(directory) = fs::File::open(parent)
978 {
979 let _ = directory.sync_all();
980 }
981 Ok(())
982 }
983}
984
985impl Drop for Lock {
986 fn drop(&mut self) {
987 if self.file.take().is_some() {
988 // Not committed: release the lock rather than leave a repository
989 // that no git command can write to.
990 let _ = fs::remove_file(&self.path);
991 }
992 }
993}
994
995/// The hash a repository's index is checksummed with.
996///
997/// SHA-1 unless the repository says otherwise, which is what git assumes too.
998#[must_use]
999pub fn object_hash(object_format: Option<&str>) -> gix_hash::Kind {
1000 match object_format {
1001 Some(format) if format.eq_ignore_ascii_case("sha256") => gix_hash::Kind::Sha256,
1002 _ => gix_hash::Kind::Sha1,
1003 }
1004}