Skip to main content

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}