Skip to main content

mkit_core/
index.rs

1//! Staging-area index.
2//!
3//! On-disk layout per `docs/specs/SPEC-INDEX.md`:
4//!
5//! ```text
6//! [4B magic "MKIX"][1B version=3][4B LE entry_count][entries...][32B BLAKE3 checksum]
7//! entry := [1B status][32B object_hash][8B LE mtime_ns][8B LE size]
8//!          [8B LE ino][8B LE ctime_ns][2B LE path_len][path_len UTF-8 bytes]
9//! ```
10//!
11//! `mtime_ns`/`size`/`ino`/`ctime_ns` are the stat cache (SPEC-INDEX
12//! §"stat cache"): when a worktree file's live `stat` matches them,
13//! `add`/`status` may reuse `object_hash` without re-reading or
14//! re-hashing the content — O(stat) instead of O(content) for unchanged
15//! files. `mtime_ns == 0` is the sentinel for "no cache, always
16//! re-hash". Writers smudge (zero) the cache of any entry
17//! whose mtime falls within the racy window of the index write itself
18//! — see [`write_index`].
19//!
20//! SPEC-INDEX §2 is normative on the magic value — readers MUST reject
21//! any other magic.
22//!
23//! Path rules (SPEC-INDEX §2): non-empty, no leading `/`, no `.`/`..`
24//! segments, no NULs/backslashes, and never under `.mkit/` or `.git/`.
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::io::{self, Read};
29use std::path::PathBuf;
30
31use crate::atomic::write_atomic;
32use crate::hash::{self, HASH_LEN, Hash};
33use crate::layout::RepoLayout;
34use crate::object::{EntryMode, Object};
35use crate::store::{MAX_TREE_DEPTH, ObjectStore, StoreError};
36
37/// Magic bytes — ASCII `"MKIX"`.
38pub const MAGIC: [u8; 4] = *b"MKIX";
39/// Checksummed index format. Other versions are rejected without rewriting.
40pub const FORMAT_VERSION: u8 = 0x03;
41/// Hard cap on a serialised index file (64 MiB), per SPEC-INDEX §4.
42pub const MAX_INDEX_BYTES: u64 = 64 * 1024 * 1024;
43/// Hard cap on a single entry's path length (SPEC-INDEX §2).
44pub const MAX_PATH_LEN: usize = 4096;
45
46/// Default location of the index file relative to the worktree root.
47pub const INDEX_FILE: &str = ".mkit/index";
48
49/// Status byte for an index entry. Values match SPEC-INDEX §3.
50#[repr(u8)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum EntryStatus {
53    /// `0x00` — path scheduled for deletion in the next commit.
54    Removed = 0x00,
55    /// `0x01` — regular file blob.
56    Blob = 0x01,
57    /// `0x02` — reserved for subtree staging; currently unused.
58    Tree = 0x02,
59    /// `0x03` — symbolic link, blob payload is the target string.
60    Symlink = 0x03,
61    /// `0x04` — executable blob (mode bit per SPEC-OBJECTS §4.2).
62    Executable = 0x04,
63}
64
65impl EntryStatus {
66    /// Decode a status byte. Returns `None` on unknown values.
67    #[must_use]
68    pub fn from_byte(b: u8) -> Option<Self> {
69        match b {
70            0x00 => Some(Self::Removed),
71            0x01 => Some(Self::Blob),
72            0x02 => Some(Self::Tree),
73            0x03 => Some(Self::Symlink),
74            0x04 => Some(Self::Executable),
75            _ => None,
76        }
77    }
78}
79
80/// One staged entry.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct IndexEntry {
83    /// Repo-relative path with `/` separators.
84    pub path: String,
85    /// Status byte.
86    pub status: EntryStatus,
87    /// Object hash; `[0;32]` for removed entries.
88    pub object_hash: Hash,
89    /// Stat cache: worktree mtime (nanoseconds since the Unix epoch,
90    /// saturating) observed when `object_hash` was computed. `0` =
91    /// no cache — the file must be re-read and re-hashed to compare.
92    pub mtime_ns: u64,
93    /// Stat cache: file size in bytes observed when `object_hash` was
94    /// computed. Only meaningful when `mtime_ns != 0`.
95    pub size: u64,
96    /// Stat cache: inode number (0 on platforms without one, or when
97    /// uncached). Catches replace-by-rename swaps that preserve
98    /// mtime+size — the replacement file has a different inode.
99    pub ino: u64,
100    /// Stat cache: status-change time (ctime) in saturating ns. ctime
101    /// cannot be set from userspace, so it catches `touch -r`-style
102    /// timestamp restoration after an edit. 0 = don't check.
103    pub ctime_ns: u64,
104}
105
106/// In-memory staging index.
107///
108/// `entries` stays `pub` for the many read-only call sites across the CLI
109/// (`.iter()`, indexing, `.len()`) that predate the path index below and
110/// have no reason to route through a method. Any code that *mutates*
111/// `entries` — inserting, removing, or reordering — MUST go through
112/// [`Index::upsert_entry`], [`Index::remove_entry_at`],
113/// [`Index::remove_path`], or [`Index::retain_entries`] instead of touching
114/// the `Vec` directly, or `by_path` silently goes stale (see those methods'
115/// docs). In-place field mutation of an entry already at a known position
116/// (status/hash/stat-cache updates that leave `path` unchanged) is fine
117/// either way, since it never moves or renames anything `by_path` tracks.
118#[derive(Debug, Default, Clone)]
119pub struct Index {
120    /// Entries in insertion order.
121    pub entries: Vec<IndexEntry>,
122    /// `path -> position in entries`, maintained by every mutation that
123    /// goes through this type's own methods (issue #708). Lets
124    /// `find_entry`/`tracks_path_or_descendant`/`has_tracked_file_at` —
125    /// each called up to three times per staged file by `mkit add -A` —
126    /// answer in `O(log n)` instead of the `O(n)` linear scan that made
127    /// staging N files cost `O(N^2)` overall. A `BTreeMap` (not a
128    /// `HashMap`) so it can *also* answer `tracks_path_or_descendant`'s
129    /// ancestor/descendant prefix query via a sorted range scan, without a
130    /// second structure to keep in sync.
131    by_path: BTreeMap<String, usize>,
132}
133
134/// Value equality compares staged content only — `by_path` is a derived
135/// lookup cache, not user-visible state, and two indexes with identical
136/// entries are equal regardless of whether their caches happen to be
137/// populated (e.g. one built via [`deserialize`], the other via
138/// `entries.push` in a test fixture that never queries it).
139impl PartialEq for Index {
140    fn eq(&self, other: &Self) -> bool {
141        self.entries == other.entries
142    }
143}
144
145impl Eq for Index {}
146
147impl Index {
148    /// Construct an empty index.
149    #[must_use]
150    pub const fn new() -> Self {
151        Self {
152            entries: Vec::new(),
153            by_path: BTreeMap::new(),
154        }
155    }
156
157    /// Build an index from an already path-unique entry vec (e.g. freshly
158    /// parsed by [`deserialize`]), populating `by_path` in one `O(n log n)`
159    /// pass. `pub(crate)` so other `mkit-core` modules that assemble an
160    /// `Index` directly from a `Vec<IndexEntry>` (test fixtures in
161    /// `worktree`, `ops::stash`, `ops::gc`) don't need to duplicate this,
162    /// now that `by_path` makes the old `Index { entries }` struct-literal
163    /// construction unavailable outside this module.
164    ///
165    /// # Panics
166    /// Panics (via the debug-only consistency check) if `entries` contains
167    /// duplicate paths — callers must pre-validate uniqueness, same
168    /// contract as `deserialize`'s `seen_paths` check.
169    pub(crate) fn from_entries(entries: Vec<IndexEntry>) -> Self {
170        let mut idx = Self {
171            entries,
172            by_path: BTreeMap::new(),
173        };
174        idx.rebuild_path_index();
175        idx
176    }
177
178    /// Find an entry by path. `O(log n)`.
179    #[must_use]
180    pub fn find_entry(&self, path: &str) -> Option<usize> {
181        self.by_path.get(path).copied()
182    }
183
184    /// `true` if `path` is itself tracked (a non-removed entry) or is an
185    /// ancestor directory of a tracked path. Used to decide whether an
186    /// ignored worktree path must still be visited because it (or its
187    /// subtree) holds tracked content. `O(log n + k)`, `k` = number of
188    /// tracked entries directly under `path`.
189    #[must_use]
190    pub fn tracks_path_or_descendant(&self, path: &str) -> bool {
191        if let Some(&pos) = self.by_path.get(path)
192            && self.entries[pos].status != EntryStatus::Removed
193        {
194            return true;
195        }
196        let mut prefix = String::with_capacity(path.len() + 1);
197        prefix.push_str(path);
198        prefix.push('/');
199        self.by_path
200            .range(prefix.clone()..)
201            .take_while(|(p, _)| p.starts_with(prefix.as_str()))
202            .any(|(_, &pos)| self.entries[pos].status != EntryStatus::Removed)
203    }
204
205    /// `true` if a tracked (non-removed) entry exists at *exactly* `path`.
206    ///
207    /// Because the index stores only leaf paths (files / symlinks / exec
208    /// files, never directories), a hit means `path` is tracked as a
209    /// non-directory object. Used by the untracked-discovery walks to detect
210    /// a worktree directory that shadows a tracked file: git suppresses the
211    /// directory's contents as untracked in that case (#288), reporting only
212    /// the tracked-side deletion. A `Removed` tombstone does **not** count —
213    /// the path is no longer tracked, so its replacement is genuinely
214    /// untracked. `O(log n)`.
215    #[must_use]
216    pub fn has_tracked_file_at(&self, path: &str) -> bool {
217        self.find_entry(path)
218            .is_some_and(|i| self.entries[i].status != EntryStatus::Removed)
219    }
220
221    /// Count non-removed entries.
222    #[must_use]
223    pub fn staged_count(&self) -> usize {
224        self.entries
225            .iter()
226            .filter(|e| e.status != EntryStatus::Removed)
227            .count()
228    }
229
230    /// Insert `entry`, replacing any existing entry at the same path.
231    /// `O(log n)`. The sanctioned way to add or wholesale-replace an
232    /// entry — unlike a direct `entries.push`/`entries[i] = entry`, this
233    /// keeps `by_path` in lockstep.
234    pub fn upsert_entry(&mut self, entry: IndexEntry) {
235        if let Some(&pos) = self.by_path.get(entry.path.as_str()) {
236            self.entries[pos] = entry;
237        } else {
238            let pos = self.entries.len();
239            self.by_path.insert(entry.path.clone(), pos);
240            self.entries.push(entry);
241        }
242        self.debug_assert_consistent();
243    }
244
245    /// Remove and return the entry at `pos`. `O(n)` — same asymptotic cost
246    /// as the underlying `Vec::remove` shift (every later entry's position
247    /// changes), so `by_path` is fully rebuilt. Intended for single-path
248    /// removals (`rm`/`restore`/conflict abort), not a per-file staging
249    /// loop.
250    ///
251    /// # Panics
252    /// Panics if `pos >= self.entries.len()` (same as `Vec::remove`).
253    pub fn remove_entry_at(&mut self, pos: usize) -> IndexEntry {
254        let removed = self.entries.remove(pos);
255        self.rebuild_path_index();
256        removed
257    }
258
259    /// Remove the entry at `path`, if any tracked or tombstoned entry
260    /// exists there. `O(log n)` to find it, `O(n)` to remove (see
261    /// [`Index::remove_entry_at`]).
262    pub fn remove_path(&mut self, path: &str) -> Option<IndexEntry> {
263        self.find_entry(path).map(|pos| self.remove_entry_at(pos))
264    }
265
266    /// Retain only entries matching `keep`, rebuilding `by_path` in one
267    /// pass afterward. `O(n)` — the same cost `Vec::retain` already pays.
268    pub fn retain_entries(&mut self, keep: impl FnMut(&IndexEntry) -> bool) {
269        self.entries.retain(keep);
270        self.rebuild_path_index();
271    }
272
273    /// Remove any entry that conflicts with staging `path` as a file leaf:
274    /// a tracked ancestor directory-as-file (blocks descending into it), or
275    /// a tracked descendant nested under `path` treated as a directory (the
276    /// reverse conflict). An entry at exactly `path` is left untouched —
277    /// callers replace/insert it separately via [`Index::upsert_entry`].
278    ///
279    /// `O(depth)` in the common case where staging `path` has no conflict
280    /// (checked via `by_path` rather than a full scan of the index); falls
281    /// back to an `O(n)` retain + rebuild only when a conflict is actually
282    /// found, which is rare relative to the number of files staged.
283    pub fn remove_directory_conflicts(&mut self, path: &str) {
284        let has_ancestor_conflict =
285            ancestor_prefixes(path).any(|anc| self.by_path.contains_key(anc));
286        let descendant_prefix = format!("{path}/");
287        let has_descendant_conflict = self
288            .by_path
289            .range(descendant_prefix.clone()..)
290            .next()
291            .is_some_and(|(p, _)| p.starts_with(descendant_prefix.as_str()));
292        if !has_ancestor_conflict && !has_descendant_conflict {
293            return;
294        }
295        self.entries.retain(|entry| {
296            entry.path == path
297                || !(path_descends_from(&entry.path, path) || path_descends_from(path, &entry.path))
298        });
299        self.rebuild_path_index();
300    }
301
302    /// Rebuild `by_path` from `entries` in one `O(n log n)` pass. Called by
303    /// every mutation method that can move/remove entries at more than one
304    /// position at once.
305    fn rebuild_path_index(&mut self) {
306        self.by_path.clear();
307        for (i, e) in self.entries.iter().enumerate() {
308            self.by_path.insert(e.path.clone(), i);
309        }
310        self.debug_assert_consistent();
311    }
312
313    /// Debug-only consistency check for `by_path`: same length as
314    /// `entries`, and every entry's path maps back to its own position. A
315    /// mismatch means some code mutated `entries` directly instead of going
316    /// through `upsert_entry`/`remove_entry_at`/`remove_path`/
317    /// `retain_entries`/`remove_directory_conflicts`. Exercised by the
318    /// `by_path_*` tests below and by every other index test indirectly
319    /// (each mutation call re-checks itself).
320    #[cfg(debug_assertions)]
321    fn debug_assert_consistent(&self) {
322        debug_assert_eq!(
323            self.by_path.len(),
324            self.entries.len(),
325            "Index path map desynced from entries (missed upsert/remove/retain?)"
326        );
327        for (i, e) in self.entries.iter().enumerate() {
328            debug_assert_eq!(
329                self.by_path.get(e.path.as_str()),
330                Some(&i),
331                "Index path map has a stale/dangling position for '{}'",
332                e.path
333            );
334        }
335    }
336
337    #[cfg(not(debug_assertions))]
338    fn debug_assert_consistent(&self) {}
339
340    /// Serialise to the on-disk byte form per SPEC-INDEX §2.
341    ///
342    /// # Panics
343    /// Panics if any entry's path exceeds `u16::MAX` bytes; callers
344    /// should reject such paths via [`validate_index_path`] earlier.
345    #[must_use]
346    pub fn serialize(&self) -> Vec<u8> {
347        // Pre-compute capacity: header + per-entry fixed overhead +
348        // path lengths.
349        let body: usize = self
350            .entries
351            .iter()
352            .map(|e| 1 + HASH_LEN + 8 + 8 + 8 + 8 + 2 + e.path.len())
353            .sum();
354        let mut out = Vec::with_capacity(9 + body + HASH_LEN);
355        out.extend_from_slice(&MAGIC);
356        out.push(FORMAT_VERSION);
357        let count = u32::try_from(self.entries.len()).expect("index entry count fits in u32");
358        out.extend_from_slice(&count.to_le_bytes());
359        for entry in &self.entries {
360            out.push(entry.status as u8);
361            out.extend_from_slice(&entry.object_hash);
362            out.extend_from_slice(&entry.mtime_ns.to_le_bytes());
363            out.extend_from_slice(&entry.size.to_le_bytes());
364            out.extend_from_slice(&entry.ino.to_le_bytes());
365            out.extend_from_slice(&entry.ctime_ns.to_le_bytes());
366            let path_len =
367                u16::try_from(entry.path.len()).expect("index entry path length fits in u16");
368            out.extend_from_slice(&path_len.to_le_bytes());
369            out.extend_from_slice(entry.path.as_bytes());
370        }
371        out.extend_from_slice(&hash::hash(&out));
372        out
373    }
374}
375
376/// Yield every strict ancestor directory prefix of `path`, shallowest
377/// first — e.g. `"a/b/c.txt"` yields `"a"`, then `"a/b"` (never `path`
378/// itself). Used by [`Index::remove_directory_conflicts`] to check for a
379/// tracked ancestor-as-file in `O(depth)` instead of scanning the index.
380fn ancestor_prefixes(path: &str) -> impl Iterator<Item = &str> {
381    path.match_indices('/').map(move |(i, _)| &path[..i])
382}
383
384/// `true` if `path` is a strict descendant of `base` (`base` followed by a
385/// `/` and at least one more byte). Mirrors
386/// `mkit_cli::commands::index_path_descends_from` — duplicated here rather
387/// than shared across the crate boundary, since `mkit-core` cannot depend
388/// on `mkit-cli`.
389fn path_descends_from(path: &str, base: &str) -> bool {
390    path.len() > base.len()
391        && path.starts_with(base)
392        && path.as_bytes().get(base.len()) == Some(&b'/')
393}
394
395/// Errors returned by the index subsystem.
396#[derive(Debug, thiserror::Error)]
397pub enum IndexError {
398    /// Magic bytes were not `"MKIX"`.
399    #[error("index file has wrong magic (expected MKIX)")]
400    BadMagic,
401    /// `version` byte was not the current `FORMAT_VERSION`.
402    #[error("unsupported index version: {0:#x}")]
403    UnsupportedVersion(u8),
404    /// Status byte was outside the documented {0x00..=0x04} range.
405    #[error("index entry has unknown status byte {0:#x}")]
406    BadStatus(u8),
407    /// Truncated or otherwise malformed entry.
408    #[error("index file is corrupt")]
409    Corrupt,
410    /// File exceeded [`MAX_INDEX_BYTES`].
411    #[error("index file too large (>{MAX_INDEX_BYTES} bytes)")]
412    TooLarge,
413    /// Path failed [`validate_index_path`].
414    #[error("invalid index path '{0}'")]
415    InvalidPath(String),
416    /// Path appeared more than once in the same index.
417    #[error("duplicate index path '{0}'")]
418    DuplicatePath(String),
419    /// A `Removed` entry carried a nonzero `object_hash` (SPEC-INDEX §3
420    /// requires the all-zero hash for removals).
421    #[error("removed index entry '{0}' has nonzero object_hash")]
422    RemovedHasHash(String),
423    /// Path UTF-8 decoding failed.
424    #[error("index path is not valid UTF-8")]
425    InvalidPathEncoding,
426    /// Underlying I/O failure.
427    #[error(transparent)]
428    Io(#[from] io::Error),
429    /// Object store lookup/decoding failed while deriving an index from a tree.
430    #[error(transparent)]
431    Store(#[from] StoreError),
432    /// A tree walk found a non-tree object where a tree hash was expected.
433    #[error("object is not a tree")]
434    NotTree,
435    /// A tree walk exceeded [`MAX_TREE_DEPTH`] nesting levels — likely a
436    /// crafted untrusted repo trying to overflow the native stack.
437    #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
438    TreeTooDeep,
439}
440
441/// Result alias used throughout this module.
442pub type IndexResult<T> = Result<T, IndexError>;
443
444/// Deserialise bytes into an [`Index`].
445///
446/// # Errors
447/// See [`IndexError`].
448///
449/// # Panics
450/// Panics only if internal fixed-width slicing is wrong, which is
451/// impossible by construction (lengths are bounds-checked first).
452pub fn deserialize(data: &[u8]) -> IndexResult<Index> {
453    if data.len() as u64 > MAX_INDEX_BYTES {
454        return Err(IndexError::TooLarge);
455    }
456    if data.len() < 9 {
457        return Err(IndexError::Corrupt);
458    }
459    if data[0..4] != MAGIC {
460        return Err(IndexError::BadMagic);
461    }
462    let version = data[4];
463    if version != FORMAT_VERSION {
464        return Err(IndexError::UnsupportedVersion(version));
465    }
466    if data.len() < 9 + HASH_LEN {
467        return Err(IndexError::Corrupt);
468    }
469    let (data, checksum) = data.split_at(data.len() - HASH_LEN);
470    if hash::hash(data).as_slice() != checksum {
471        return Err(IndexError::Corrupt);
472    }
473    // status + object hash + four stat-cache fields + path length.
474    let min_entry_len = 1 + HASH_LEN + 32 + 2;
475    let count = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
476    // Reject an attacker-supplied `count` that is impossible given the
477    // remaining bytes. The minimum wire-length of an entry is 67 bytes
478    // (empty path). Without this up-front check the loop would walk
479    // `count` iterations before failing — trivially triggered with a
480    // 9-byte buffer declaring `count = u32::MAX`.
481    // Mirrors the same up-front bound used in `serialize.rs`.
482    if (count as u64).saturating_mul(min_entry_len as u64) > data.len() as u64 {
483        return Err(IndexError::Corrupt);
484    }
485    let mut entries = Vec::with_capacity(count.min(1024)); // bound initial alloc
486    let mut seen_paths = std::collections::HashSet::with_capacity(count.min(1024));
487    let mut offset = 9usize;
488    for _ in 0..count {
489        if offset + min_entry_len > data.len() {
490            return Err(IndexError::Corrupt);
491        }
492        let status =
493            EntryStatus::from_byte(data[offset]).ok_or(IndexError::BadStatus(data[offset]))?;
494        offset += 1;
495        let mut object_hash = [0u8; HASH_LEN];
496        object_hash.copy_from_slice(&data[offset..offset + HASH_LEN]);
497        offset += HASH_LEN;
498        let mut next_u64 = || {
499            let v = u64::from_le_bytes(data[offset..offset + 8].try_into().expect("8 bytes"));
500            offset += 8;
501            v
502        };
503        let (mtime_ns, size, ino, ctime_ns) = (next_u64(), next_u64(), next_u64(), next_u64());
504        let path_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
505        offset += 2;
506        if path_len > MAX_PATH_LEN {
507            return Err(IndexError::Corrupt);
508        }
509        if offset + path_len > data.len() {
510            return Err(IndexError::Corrupt);
511        }
512        let path_bytes = &data[offset..offset + path_len];
513        let path = core::str::from_utf8(path_bytes)
514            .map_err(|_| IndexError::InvalidPathEncoding)?
515            .to_string();
516        offset += path_len;
517        if !validate_index_path(&path) {
518            return Err(IndexError::InvalidPath(path));
519        }
520        if !seen_paths.insert(path.clone()) {
521            return Err(IndexError::DuplicatePath(path));
522        }
523        if status == EntryStatus::Removed && object_hash != hash::ZERO {
524            return Err(IndexError::RemovedHasHash(path));
525        }
526        entries.push(IndexEntry {
527            path,
528            status,
529            object_hash,
530            mtime_ns,
531            size,
532            ino,
533            ctime_ns,
534        });
535    }
536    if offset != data.len() {
537        return Err(IndexError::Corrupt);
538    }
539    Ok(Index::from_entries(entries))
540}
541
542/// Read this worktree's staging index. Returns an empty index if the
543/// file is absent. An existing zero-length file is corruption. The index is per-worktree state —
544/// see [`crate::layout`].
545pub fn read_index(layout: &RepoLayout) -> IndexResult<Index> {
546    let path = layout.index_file();
547    let file = match fs::File::open(&path) {
548        Ok(f) => f,
549        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Index::new()),
550        Err(e) => return Err(IndexError::Io(e)),
551    };
552    let meta = file.metadata()?;
553    if meta.len() == 0 {
554        return Err(IndexError::Corrupt);
555    }
556    if meta.len() > MAX_INDEX_BYTES {
557        return Err(IndexError::TooLarge);
558    }
559    let mut bytes = Vec::new();
560    file.take(MAX_INDEX_BYTES + 1).read_to_end(&mut bytes)?;
561    if bytes.len() as u64 > MAX_INDEX_BYTES {
562        return Err(IndexError::TooLarge);
563    }
564    let mut idx = deserialize(&bytes)?;
565    // git's racy-clean rule, applied at read time: an entry whose
566    // cached mtime is not safely OLDER than the index file itself may
567    // have been modified after hashing without its stat changing —
568    // within the filesystem timestamp granularity the modification is
569    // invisible to stat. Treat such entries as uncached (zero
570    // sentinel) so callers re-hash them; the next index write (whose
571    // file mtime is then newer) heals the cache.
572    // Same conversion (incl. 0-sentinel + saturation semantics) as the
573    // entry mtimes it is compared against — one implementation only.
574    let index_mtime_ns = crate::worktree::mtime_nanos(&meta);
575    // Window sizing, like git's USE_NSEC — but judged PER ENTRY: the
576    // tight 10ms window is only safe when BOTH the index file's mtime
577    // and the entry's recorded worktree mtime show sub-second
578    // precision. A worktree file whose mtime is whole-second (vfat/
579    // SMB/NFS mounts, tar/touch -t/rsync-truncated timestamps) could
580    // be rewritten within its coarse tick without the stat changing,
581    // so such entries keep the conservative 1s window.
582    let index_ns_precise = !index_mtime_ns.is_multiple_of(1_000_000_000);
583    for e in &mut idx.entries {
584        if e.mtime_ns == 0 {
585            continue;
586        }
587        let window = if index_ns_precise && !e.mtime_ns.is_multiple_of(1_000_000_000) {
588            RACY_WINDOW_NS / 100
589        } else {
590            RACY_WINDOW_NS
591        };
592        if e.mtime_ns >= index_mtime_ns.saturating_sub(window) {
593            e.mtime_ns = 0;
594            e.size = 0;
595            e.ino = 0;
596            e.ctime_ns = 0;
597        }
598    }
599    Ok(idx)
600}
601
602/// The racy-clean window: an entry whose cached mtime is within this
603/// span of the index file's own mtime may have been modified after
604/// hashing without its stat changing (filesystem timestamp granularity
605/// can be as coarse as 1s), so its cache cannot be trusted. One second
606/// is the conservative bound git uses for second-granularity
607/// filesystems.
608const RACY_WINDOW_NS: u64 = 1_000_000_000;
609
610/// Write this worktree's staging index atomically. The containing
611/// directory is created if absent.
612///
613/// Stat-cache fields are written verbatim; the racy-clean rule is
614/// applied at READ time against the index file's own mtime (see
615/// [`read_index`]). Note a read-modify-write command that loads a
616/// racy-marked entry persists the zeroed cache for it — sound (zero
617/// always re-hashes) and healed by the next add/status touching the
618/// path; only the racy window's worth of entries is affected.
619pub fn write_index(layout: &RepoLayout, idx: &Index) -> IndexResult<()> {
620    let path = layout.index_file();
621    let bytes = idx.serialize();
622    if bytes.len() as u64 > MAX_INDEX_BYTES {
623        return Err(IndexError::TooLarge);
624    }
625    write_atomic(&path, &bytes, true)?;
626    Ok(())
627}
628
629/// Materialize a staging index from a committed tree.
630///
631/// This is used after commands that move `HEAD` and restore the
632/// worktree so the index keeps matching the new commit snapshot. Tree
633/// entries are recursively flattened into leaf paths; removed entries
634/// are not represented because a committed tree has no tombstones.
635///
636/// # Errors
637/// Propagates object-store errors and returns [`IndexError::NotTree`]
638/// if `tree_hash` does not point at a tree object.
639pub fn from_tree(store: &ObjectStore, tree_hash: Hash) -> IndexResult<Index> {
640    let mut entries = Vec::new();
641    push_tree_entries(store, tree_hash, "", &mut entries, 0)?;
642    Ok(Index::from_entries(entries))
643}
644
645fn push_tree_entries(
646    store: &ObjectStore,
647    tree_hash: Hash,
648    prefix: &str,
649    entries: &mut Vec<IndexEntry>,
650    depth: usize,
651) -> IndexResult<()> {
652    if depth > MAX_TREE_DEPTH {
653        return Err(IndexError::TreeTooDeep);
654    }
655    let Object::Tree(tree) = store.read_object(&tree_hash)? else {
656        return Err(IndexError::NotTree);
657    };
658    for entry in tree.entries {
659        let name = String::from_utf8(entry.name).map_err(|_| IndexError::InvalidPathEncoding)?;
660        let path = if prefix.is_empty() {
661            name
662        } else {
663            format!("{prefix}/{name}")
664        };
665        match entry.mode {
666            EntryMode::Tree => {
667                push_tree_entries(store, entry.object_hash, &path, entries, depth + 1)?;
668            }
669            EntryMode::Blob | EntryMode::Executable | EntryMode::Symlink => {
670                if !validate_index_path(&path) {
671                    return Err(IndexError::InvalidPath(path));
672                }
673                let status = match entry.mode {
674                    EntryMode::Blob => EntryStatus::Blob,
675                    EntryMode::Executable => EntryStatus::Executable,
676                    EntryMode::Symlink => EntryStatus::Symlink,
677                    EntryMode::Tree => unreachable!("handled above"),
678                };
679                entries.push(IndexEntry {
680                    path,
681                    status,
682                    object_hash: entry.object_hash,
683                    // A tree-derived entry has no observed worktree
684                    // stat — zero sentinel means "re-hash to compare".
685                    mtime_ns: 0,
686                    size: 0,
687                    ino: 0,
688                    ctime_ns: 0,
689                });
690            }
691        }
692    }
693    Ok(())
694}
695
696/// Compute the absolute path of this worktree's index file.
697#[must_use]
698pub fn index_path(layout: &RepoLayout) -> PathBuf {
699    layout.index_file()
700}
701
702/// Validate a staged path: non-empty, relative, no traversal, no NUL,
703/// no backslash, never under `.mkit/` or `.git/`.
704#[must_use]
705pub fn validate_index_path(path: &str) -> bool {
706    if path.is_empty() {
707        return false;
708    }
709    if path.starts_with('/') {
710        return false;
711    }
712    if path.len() > MAX_PATH_LEN {
713        return false;
714    }
715    if path == ".mkit" || path == ".git" {
716        return false;
717    }
718    if path.starts_with(".mkit/") || path.starts_with(".git/") {
719        return false;
720    }
721    for part in path.split('/') {
722        if part.is_empty() {
723            return false;
724        }
725        if part == "." || part == ".." {
726            return false;
727        }
728        for &c in part.as_bytes() {
729            if c == 0 || c == b'\\' {
730                return false;
731            }
732        }
733    }
734    true
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::hash;
741    use tempfile::TempDir;
742
743    fn seed_hash(s: &str) -> Hash {
744        hash::hash(s.as_bytes())
745    }
746
747    #[test]
748    fn empty_index_round_trip() {
749        let idx = Index::new();
750        let bytes = idx.serialize();
751        // 4 magic + 1 version + 4 count = 9 bytes.
752        assert_eq!(bytes.len(), 41);
753        assert_eq!(&bytes[0..4], &MAGIC);
754        assert_eq!(bytes[4], FORMAT_VERSION);
755        assert_eq!(&bytes[5..9], &0u32.to_le_bytes());
756        let parsed = deserialize(&bytes).unwrap();
757        assert_eq!(parsed, idx);
758    }
759
760    // ---- stat cache -----------------------------------------------------
761
762    /// Pinned vector: header(9) + status(1) + hash(32) +
763    /// `mtime_ns`(8) + `size`(8) + `ino`(8) + `ctime_ns`(8) +
764    /// `path_len`(2) + "hello.txt"(9) = 85 bytes.
765    #[test]
766    fn single_entry_pinned_bytes() {
767        let h = seed_hash("hello");
768        let idx = Index::from_entries(vec![IndexEntry {
769            path: "hello.txt".to_string(),
770            status: EntryStatus::Blob,
771            object_hash: h,
772            mtime_ns: 0x0102_0304_0506_0708,
773            size: 11,
774            ino: 0x0A0B_0C0D_0E0F_1011,
775            ctime_ns: 0x1112_1314_1516_1718,
776        }]);
777        let bytes = idx.serialize();
778        assert_eq!(bytes.len(), 117);
779        let mut expected = Vec::new();
780        expected.extend_from_slice(b"MKIX");
781        expected.push(0x03); // version
782        expected.extend_from_slice(&1u32.to_le_bytes());
783        expected.push(0x01); // Blob
784        expected.extend_from_slice(&h);
785        expected.extend_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes());
786        expected.extend_from_slice(&11u64.to_le_bytes());
787        expected.extend_from_slice(&0x0A0B_0C0D_0E0F_1011u64.to_le_bytes());
788        expected.extend_from_slice(&0x1112_1314_1516_1718u64.to_le_bytes());
789        expected.extend_from_slice(&9u16.to_le_bytes());
790        expected.extend_from_slice(b"hello.txt");
791        expected.extend_from_slice(&hash::hash(&expected));
792        assert_eq!(bytes, expected, "byte layout is pinned");
793        assert_eq!(deserialize(&bytes).unwrap(), idx);
794    }
795
796    #[test]
797    fn rejects_count_overflow_at_min_entry_bytes() {
798        // 9-byte header declaring u32::MAX entries: the minimum entry is
799        // 67 bytes, so this must fail fast, before looping.
800        let mut bytes = Vec::new();
801        bytes.extend_from_slice(b"MKIX");
802        bytes.push(FORMAT_VERSION);
803        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
804        bytes.extend_from_slice(&hash::hash(&bytes));
805        assert!(matches!(deserialize(&bytes), Err(IndexError::Corrupt)));
806        // One entry declared, only 60 bytes of body: still corrupt.
807        let mut short = Vec::new();
808        short.extend_from_slice(b"MKIX");
809        short.push(FORMAT_VERSION);
810        short.extend_from_slice(&1u32.to_le_bytes());
811        short.extend_from_slice(&[0u8; 60]);
812        short.extend_from_slice(&hash::hash(&short));
813        assert!(matches!(deserialize(&short), Err(IndexError::Corrupt)));
814    }
815
816    #[test]
817    fn rejects_unknown_version_0x04() {
818        let mut bytes = Vec::new();
819        bytes.extend_from_slice(b"MKIX");
820        bytes.push(0x04);
821        bytes.extend_from_slice(&0u32.to_le_bytes());
822        assert!(matches!(
823            deserialize(&bytes),
824            Err(IndexError::UnsupportedVersion(0x04))
825        ));
826    }
827
828    #[test]
829    fn rejects_obsolete_versions_without_rewriting_staging() {
830        let dir = TempDir::new().unwrap();
831        let layout = RepoLayout::single(dir.path());
832        fs::create_dir_all(layout.worktree_state_dir()).unwrap();
833        for version in [1, 2] {
834            let mut bytes = Index::new().serialize();
835            bytes[4] = version;
836            fs::write(layout.index_file(), &bytes).unwrap();
837            assert!(
838                matches!(read_index(&layout), Err(IndexError::UnsupportedVersion(v)) if v == version)
839            );
840            assert_eq!(fs::read(layout.index_file()).unwrap(), bytes);
841        }
842    }
843
844    /// git's racy-clean rule: an entry whose mtime is within the
845    /// filesystem-timestamp granularity of the index file's mtime may
846    /// have been modified after hashing without the stat changing —
847    /// its cache must be ignored on read so the caller re-hashes.
848    #[test]
849    fn read_index_invalidates_racy_entries() {
850        let dir = TempDir::new().unwrap();
851        let layout = RepoLayout::single(dir.path());
852        let now_ns = u64::try_from(
853            std::time::SystemTime::now()
854                .duration_since(std::time::UNIX_EPOCH)
855                .unwrap()
856                .as_nanos(),
857        )
858        .unwrap();
859        let idx = Index::from_entries(vec![
860            IndexEntry {
861                path: "racy.txt".to_string(),
862                status: EntryStatus::Blob,
863                object_hash: seed_hash("racy"),
864                mtime_ns: now_ns,
865                size: 4,
866                ino: 0,
867                ctime_ns: 0,
868            },
869            IndexEntry {
870                path: "settled.txt".to_string(),
871                status: EntryStatus::Blob,
872                object_hash: seed_hash("settled"),
873                mtime_ns: now_ns - 10_000_000_000, // 10s ago
874                size: 7,
875                ino: 0,
876                ctime_ns: 0,
877            },
878        ]);
879        write_index(&layout, &idx).unwrap();
880        // Pin the index FILE's mtime to exactly the racy entry's time so
881        // the test is deterministic regardless of scheduling delays and
882        // the granularity-derived window size: an entry whose mtime
883        // equals the index mtime is racy under any window.
884        let f = fs::File::options()
885            .write(true)
886            .open(index_path(&layout))
887            .unwrap();
888        f.set_times(
889            fs::FileTimes::new()
890                .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_nanos(now_ns)),
891        )
892        .unwrap();
893        drop(f);
894        let read = read_index(&layout).unwrap();
895        let racy = &read.entries[read.find_entry("racy.txt").unwrap()];
896        let settled = &read.entries[read.find_entry("settled.txt").unwrap()];
897        assert_eq!(
898            racy.mtime_ns, 0,
899            "an entry touched within the racy window must lose its cache"
900        );
901        assert_eq!(racy.size, 0);
902        assert_eq!(settled.mtime_ns, now_ns - 10_000_000_000);
903        assert_eq!(settled.size, 7);
904    }
905
906    /// A whole-second entry mtime (vfat/SMB/tar-truncated timestamps)
907    /// must keep the conservative 1s racy window even when the index
908    /// file itself has nanosecond precision — the file could be
909    /// rewritten within its coarse tick without the stat changing.
910    #[test]
911    fn coarse_entry_mtime_keeps_one_second_window() {
912        let dir = TempDir::new().unwrap();
913        let layout = RepoLayout::single(dir.path());
914        let base_ns: u64 = 1_700_000_000_000_000_000; // whole-second tick
915        let idx = Index::from_entries(vec![
916            IndexEntry {
917                path: "coarse.txt".to_string(),
918                status: EntryStatus::Blob,
919                object_hash: seed_hash("coarse"),
920                // 500ms before the index mtime, WHOLE-second value:
921                // inside the 1s window, outside the 10ms one.
922                mtime_ns: base_ns - 1_000_000_000,
923                size: 4,
924                ino: 0,
925                ctime_ns: 0,
926            },
927            IndexEntry {
928                path: "precise.txt".to_string(),
929                status: EntryStatus::Blob,
930                object_hash: seed_hash("precise"),
931                // Same age but ns-precise: the 10ms window applies
932                // and it is safely older than the floor.
933                mtime_ns: base_ns - 1_000_000_000 + 123,
934                size: 7,
935                ino: 0,
936                ctime_ns: 0,
937            },
938        ]);
939        write_index(&layout, &idx).unwrap();
940        // Index file mtime: ns-precise, 500ms after the coarse entry.
941        let f = fs::File::options()
942            .write(true)
943            .open(index_path(&layout))
944            .unwrap();
945        f.set_times(fs::FileTimes::new().set_modified(
946            std::time::UNIX_EPOCH + std::time::Duration::from_nanos(base_ns - 500_000_000 + 777),
947        ))
948        .unwrap();
949        drop(f);
950
951        let read = read_index(&layout).unwrap();
952        let coarse = &read.entries[read.find_entry("coarse.txt").unwrap()];
953        let precise = &read.entries[read.find_entry("precise.txt").unwrap()];
954        assert_eq!(
955            coarse.mtime_ns, 0,
956            "coarse-mtime entry within 1s of the index write must be racy"
957        );
958        assert_ne!(
959            precise.mtime_ns, 0,
960            "ns-precise entry outside the 10ms window keeps its cache"
961        );
962    }
963
964    #[test]
965    fn tracks_path_or_descendant_matches_self_and_ancestors() {
966        let mut idx = Index::new();
967        idx.upsert_entry(IndexEntry {
968            path: "src/lib.rs".to_string(),
969            status: EntryStatus::Blob,
970            object_hash: seed_hash("lib"),
971            mtime_ns: 0,
972            size: 0,
973            ino: 0,
974            ctime_ns: 0,
975        });
976        idx.upsert_entry(IndexEntry {
977            path: "removed.txt".to_string(),
978            status: EntryStatus::Removed,
979            object_hash: hash::ZERO,
980            mtime_ns: 0,
981            size: 0,
982            ino: 0,
983            ctime_ns: 0,
984        });
985        // Exact tracked path and its ancestor directory both match.
986        assert!(idx.tracks_path_or_descendant("src/lib.rs"));
987        assert!(idx.tracks_path_or_descendant("src"));
988        // A prefix that is not a path-segment boundary does not match.
989        assert!(!idx.tracks_path_or_descendant("sr"));
990        // Unrelated and removed-only paths do not match.
991        assert!(!idx.tracks_path_or_descendant("docs"));
992        assert!(!idx.tracks_path_or_descendant("removed.txt"));
993    }
994
995    #[test]
996    fn has_tracked_file_at_exact_only_and_not_removed() {
997        let mut idx = Index::new();
998        idx.upsert_entry(IndexEntry {
999            path: "f".to_string(),
1000            status: EntryStatus::Blob,
1001            object_hash: seed_hash("f"),
1002            mtime_ns: 0,
1003            size: 0,
1004            ino: 0,
1005            ctime_ns: 0,
1006        });
1007        idx.upsert_entry(IndexEntry {
1008            path: "gone".to_string(),
1009            status: EntryStatus::Removed,
1010            object_hash: hash::ZERO,
1011            mtime_ns: 0,
1012            size: 0,
1013            ino: 0,
1014            ctime_ns: 0,
1015        });
1016        // Exact tracked file matches.
1017        assert!(idx.has_tracked_file_at("f"));
1018        // Unlike `tracks_path_or_descendant`, an ancestor directory does NOT
1019        // match — only an exact tracked leaf does (the collision predicate).
1020        idx.upsert_entry(IndexEntry {
1021            path: "dir/inner.txt".to_string(),
1022            status: EntryStatus::Blob,
1023            object_hash: seed_hash("inner"),
1024            mtime_ns: 0,
1025            size: 0,
1026            ino: 0,
1027            ctime_ns: 0,
1028        });
1029        assert!(!idx.has_tracked_file_at("dir"));
1030        assert!(idx.has_tracked_file_at("dir/inner.txt"));
1031        // A `Removed` tombstone must NOT suppress — the path is no longer
1032        // tracked, so a replacement at that path is genuinely untracked.
1033        assert!(!idx.has_tracked_file_at("gone"));
1034        // Unrelated path.
1035        assert!(!idx.has_tracked_file_at("other"));
1036    }
1037
1038    #[test]
1039    fn multi_entry_round_trip_with_all_statuses() {
1040        let mut idx = Index::new();
1041        idx.entries.push(IndexEntry {
1042            path: "a.txt".into(),
1043            status: EntryStatus::Blob,
1044            object_hash: seed_hash("a"),
1045            mtime_ns: 0,
1046            size: 0,
1047            ino: 0,
1048            ctime_ns: 0,
1049        });
1050        idx.entries.push(IndexEntry {
1051            path: "b/sub".into(),
1052            status: EntryStatus::Tree,
1053            object_hash: seed_hash("b"),
1054            mtime_ns: 0,
1055            size: 0,
1056            ino: 0,
1057            ctime_ns: 0,
1058        });
1059        idx.entries.push(IndexEntry {
1060            path: "c.link".into(),
1061            status: EntryStatus::Symlink,
1062            object_hash: seed_hash("c"),
1063            mtime_ns: 0,
1064            size: 0,
1065            ino: 0,
1066            ctime_ns: 0,
1067        });
1068        idx.entries.push(IndexEntry {
1069            path: "scripts/build".into(),
1070            status: EntryStatus::Executable,
1071            object_hash: seed_hash("d"),
1072            mtime_ns: 0,
1073            size: 0,
1074            ino: 0,
1075            ctime_ns: 0,
1076        });
1077        idx.entries.push(IndexEntry {
1078            path: "old.txt".into(),
1079            status: EntryStatus::Removed,
1080            object_hash: [0u8; HASH_LEN],
1081            mtime_ns: 0,
1082            size: 0,
1083            ino: 0,
1084            ctime_ns: 0,
1085        });
1086        let bytes = idx.serialize();
1087        let parsed = deserialize(&bytes).unwrap();
1088        assert_eq!(parsed, idx);
1089    }
1090
1091    #[test]
1092    fn checksum_rejects_a_bit_flip_in_an_otherwise_valid_staged_hash() {
1093        let idx = Index::from_entries(vec![IndexEntry {
1094            path: "staged".into(),
1095            status: EntryStatus::Blob,
1096            object_hash: seed_hash("A"),
1097            mtime_ns: 0,
1098            size: 0,
1099            ino: 0,
1100            ctime_ns: 0,
1101        }]);
1102        let mut bytes = idx.serialize();
1103        bytes[10] ^= 1;
1104        assert!(matches!(deserialize(&bytes), Err(IndexError::Corrupt)));
1105    }
1106
1107    #[test]
1108    fn rejects_bad_magic() {
1109        let mut bytes = Index::new().serialize();
1110        bytes[0] = b'X';
1111        let err = deserialize(&bytes).unwrap_err();
1112        assert!(matches!(err, IndexError::BadMagic));
1113    }
1114
1115    #[test]
1116    fn rejects_zmix_magic_explicitly() {
1117        // SPEC-INDEX §5: readers MUST reject `"ZMIX"`-prefixed files. We
1118        // construct the rejected magic as ASCII bytes here rather than
1119        // embedding the wrong literal string.
1120        let bytes = [
1121            0x5A,
1122            0x4D,
1123            0x49,
1124            0x58, // "ZMIX"
1125            FORMAT_VERSION,
1126            0,
1127            0,
1128            0,
1129            0,
1130        ];
1131        let err = deserialize(&bytes).unwrap_err();
1132        assert!(matches!(err, IndexError::BadMagic));
1133    }
1134
1135    #[test]
1136    fn rejects_unsupported_version() {
1137        let mut bytes = Index::new().serialize();
1138        bytes[4] = 0xFF;
1139        let err = deserialize(&bytes).unwrap_err();
1140        assert!(matches!(err, IndexError::UnsupportedVersion(0xFF)));
1141    }
1142
1143    #[test]
1144    fn rejects_truncated_header() {
1145        let err = deserialize(b"MKIX").unwrap_err();
1146        assert!(matches!(err, IndexError::Corrupt));
1147    }
1148
1149    #[test]
1150    fn rejects_truncated_entry() {
1151        let mut idx = Index::new();
1152        idx.entries.push(IndexEntry {
1153            path: "a".into(),
1154            status: EntryStatus::Blob,
1155            object_hash: seed_hash("a"),
1156            mtime_ns: 0,
1157            size: 0,
1158            ino: 0,
1159            ctime_ns: 0,
1160        });
1161        let mut bytes = idx.serialize();
1162        bytes.truncate(bytes.len() - 1); // drop the trailing path byte
1163        let err = deserialize(&bytes).unwrap_err();
1164        assert!(matches!(err, IndexError::Corrupt));
1165    }
1166
1167    #[test]
1168    fn rejects_trailing_bytes_after_declared_entries() {
1169        let mut idx = Index::new();
1170        idx.entries.push(IndexEntry {
1171            path: "a".into(),
1172            status: EntryStatus::Blob,
1173            object_hash: seed_hash("a"),
1174            mtime_ns: 0,
1175            size: 0,
1176            ino: 0,
1177            ctime_ns: 0,
1178        });
1179        let mut bytes = idx.serialize();
1180        bytes.extend_from_slice(b"junk");
1181        let err = deserialize(&bytes).unwrap_err();
1182        assert!(matches!(err, IndexError::Corrupt));
1183    }
1184
1185    #[test]
1186    fn rejects_invalid_path_on_deserialize() {
1187        let mut bytes = Vec::new();
1188        bytes.extend_from_slice(&MAGIC);
1189        bytes.push(FORMAT_VERSION);
1190        bytes.extend_from_slice(&1u32.to_le_bytes());
1191        bytes.push(EntryStatus::Blob as u8);
1192        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1193        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1194        let path = b"../escape";
1195        let path_len = u16::try_from(path.len()).unwrap();
1196        bytes.extend_from_slice(&path_len.to_le_bytes());
1197        bytes.extend_from_slice(path);
1198        bytes.extend_from_slice(&hash::hash(&bytes));
1199        let err = deserialize(&bytes).unwrap_err();
1200        assert!(matches!(err, IndexError::InvalidPath(path) if path == "../escape"));
1201    }
1202
1203    #[test]
1204    fn rejects_duplicate_paths_on_deserialize() {
1205        let mut idx = Index::new();
1206        idx.entries.push(IndexEntry {
1207            path: "same.txt".into(),
1208            status: EntryStatus::Blob,
1209            object_hash: seed_hash("a"),
1210            mtime_ns: 0,
1211            size: 0,
1212            ino: 0,
1213            ctime_ns: 0,
1214        });
1215        idx.entries.push(IndexEntry {
1216            path: "same.txt".into(),
1217            status: EntryStatus::Executable,
1218            object_hash: seed_hash("b"),
1219            mtime_ns: 0,
1220            size: 0,
1221            ino: 0,
1222            ctime_ns: 0,
1223        });
1224        let err = deserialize(&idx.serialize()).unwrap_err();
1225        assert!(matches!(err, IndexError::DuplicatePath(path) if path == "same.txt"));
1226    }
1227
1228    #[test]
1229    fn rejects_path_len_overflow() {
1230        // Hand-roll: path_len = 1000 but only 1 byte available.
1231        let mut bytes = Vec::new();
1232        bytes.extend_from_slice(&MAGIC);
1233        bytes.push(FORMAT_VERSION);
1234        bytes.extend_from_slice(&1u32.to_le_bytes());
1235        bytes.push(EntryStatus::Blob as u8);
1236        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1237        bytes.extend_from_slice(&1000u16.to_le_bytes());
1238        bytes.push(b'a');
1239        let err = deserialize(&bytes).unwrap_err();
1240        assert!(matches!(err, IndexError::Corrupt));
1241    }
1242
1243    #[test]
1244    fn rejects_unknown_status_byte() {
1245        let mut bytes = Vec::new();
1246        bytes.extend_from_slice(&MAGIC);
1247        bytes.push(FORMAT_VERSION);
1248        bytes.extend_from_slice(&1u32.to_le_bytes());
1249        bytes.push(0x77); // bogus status
1250        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1251        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1252        bytes.extend_from_slice(&0u16.to_le_bytes());
1253        bytes.extend_from_slice(&hash::hash(&bytes));
1254        let err = deserialize(&bytes).unwrap_err();
1255        assert!(matches!(err, IndexError::BadStatus(0x77)));
1256    }
1257
1258    #[test]
1259    fn rejects_removed_entry_with_nonzero_hash() {
1260        // Hand-roll a Removed (0x00) entry carrying a nonzero object_hash —
1261        // SPEC-INDEX §3 requires the all-zero hash for removals.
1262        let mut bytes = Vec::new();
1263        bytes.extend_from_slice(&MAGIC);
1264        bytes.push(FORMAT_VERSION);
1265        bytes.extend_from_slice(&1u32.to_le_bytes());
1266        bytes.push(EntryStatus::Removed as u8);
1267        bytes.extend_from_slice(&seed_hash("nonzero")); // MUST be all-zero
1268        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1269        let path = b"removed.txt";
1270        bytes.extend_from_slice(&11u16.to_le_bytes());
1271        bytes.extend_from_slice(path);
1272        bytes.extend_from_slice(&hash::hash(&bytes));
1273        let err = deserialize(&bytes).unwrap_err();
1274        assert!(matches!(err, IndexError::RemovedHasHash(p) if p == "removed.txt"));
1275    }
1276
1277    #[test]
1278    fn removed_entry_with_zero_hash_round_trips() {
1279        let mut idx = Index::new();
1280        idx.entries.push(IndexEntry {
1281            path: "gone.txt".into(),
1282            status: EntryStatus::Removed,
1283            object_hash: hash::ZERO,
1284            mtime_ns: 0,
1285            size: 0,
1286            ino: 0,
1287            ctime_ns: 0,
1288        });
1289        let round_tripped = deserialize(&idx.serialize()).unwrap();
1290        assert_eq!(round_tripped.entries[0].status, EntryStatus::Removed);
1291        assert_eq!(round_tripped.entries[0].object_hash, hash::ZERO);
1292    }
1293
1294    #[test]
1295    fn write_and_read_round_trip_via_disk() {
1296        let dir = TempDir::new().unwrap();
1297        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1298        let mut idx = Index::new();
1299        idx.entries.push(IndexEntry {
1300            path: "test.txt".into(),
1301            status: EntryStatus::Blob,
1302            object_hash: seed_hash("c"),
1303            mtime_ns: 0,
1304            size: 0,
1305            ino: 0,
1306            ctime_ns: 0,
1307        });
1308        let layout = RepoLayout::single(dir.path());
1309        write_index(&layout, &idx).unwrap();
1310        let read = read_index(&layout).unwrap();
1311        assert_eq!(read, idx);
1312    }
1313
1314    #[test]
1315    fn read_missing_file_returns_empty_index() {
1316        let dir = TempDir::new().unwrap();
1317        let idx = read_index(&RepoLayout::single(dir.path())).unwrap();
1318        assert!(idx.entries.is_empty());
1319    }
1320
1321    #[test]
1322    fn read_zero_length_file_rejects_corruption() {
1323        let dir = TempDir::new().unwrap();
1324        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1325        fs::write(dir.path().join(INDEX_FILE), b"").unwrap();
1326        assert!(matches!(
1327            read_index(&RepoLayout::single(dir.path())),
1328            Err(IndexError::Corrupt)
1329        ));
1330    }
1331
1332    #[test]
1333    fn read_oversize_file_rejected() {
1334        let dir = TempDir::new().unwrap();
1335        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1336        let path = dir.path().join(INDEX_FILE);
1337        // Sparse-extend beyond the cap; allocates effectively no blocks.
1338        let f = fs::OpenOptions::new()
1339            .write(true)
1340            .create(true)
1341            .truncate(true)
1342            .open(&path)
1343            .unwrap();
1344        f.set_len(MAX_INDEX_BYTES + 1).unwrap();
1345        drop(f);
1346        let err = read_index(&RepoLayout::single(dir.path())).unwrap_err();
1347        assert!(matches!(err, IndexError::TooLarge));
1348    }
1349
1350    #[test]
1351    fn staged_count_excludes_removed() {
1352        let mut idx = Index::new();
1353        idx.entries.push(IndexEntry {
1354            path: "a".into(),
1355            status: EntryStatus::Blob,
1356            object_hash: seed_hash("a"),
1357            mtime_ns: 0,
1358            size: 0,
1359            ino: 0,
1360            ctime_ns: 0,
1361        });
1362        idx.entries.push(IndexEntry {
1363            path: "b".into(),
1364            status: EntryStatus::Removed,
1365            object_hash: [0u8; HASH_LEN],
1366            mtime_ns: 0,
1367            size: 0,
1368            ino: 0,
1369            ctime_ns: 0,
1370        });
1371        idx.entries.push(IndexEntry {
1372            path: "c".into(),
1373            status: EntryStatus::Blob,
1374            object_hash: seed_hash("c"),
1375            mtime_ns: 0,
1376            size: 0,
1377            ino: 0,
1378            ctime_ns: 0,
1379        });
1380        assert_eq!(idx.staged_count(), 2);
1381    }
1382
1383    #[test]
1384    fn rejects_bogus_huge_count_before_loop() {
1385        // G11 regression: a 13-byte buffer whose header declares
1386        // count = u32::MAX must be rejected up-front — the
1387        // deserializer must NOT spin through u32::MAX iterations
1388        // (or allocate Vec::with_capacity(count)).
1389        let mut bytes = Vec::new();
1390        bytes.extend_from_slice(&MAGIC);
1391        bytes.push(FORMAT_VERSION);
1392        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1393        // No entries follow — buffer is just the 9-byte header.
1394        let err = deserialize(&bytes).unwrap_err();
1395        assert!(matches!(err, IndexError::Corrupt));
1396    }
1397
1398    #[test]
1399    fn validate_path_basic() {
1400        assert!(validate_index_path("a.txt"));
1401        assert!(validate_index_path("src/main.rs"));
1402        assert!(validate_index_path(".mkitignore"));
1403        assert!(!validate_index_path(""));
1404        assert!(!validate_index_path("/abs"));
1405        assert!(!validate_index_path("../escape"));
1406        assert!(!validate_index_path("a/../b"));
1407        assert!(!validate_index_path(".mkit"));
1408        assert!(!validate_index_path(".git"));
1409        assert!(!validate_index_path(".mkit/objects"));
1410        assert!(!validate_index_path(".git/HEAD"));
1411        assert!(!validate_index_path("a\\b"));
1412        assert!(!validate_index_path("a//b"));
1413    }
1414
1415    #[test]
1416    fn from_tree_flattens_tree_entries() {
1417        use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1418        use crate::serialize;
1419        use crate::store::ObjectStore;
1420
1421        fn put(store: &ObjectStore, obj: &Object) -> Hash {
1422            let bytes = serialize::serialize(obj).unwrap();
1423            store.write(&bytes).unwrap()
1424        }
1425
1426        let dir = TempDir::new().unwrap();
1427        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1428        let file = put(
1429            &store,
1430            &Object::Blob(Blob {
1431                data: b"file".to_vec(),
1432            }),
1433        );
1434        let exec = put(
1435            &store,
1436            &Object::Blob(Blob {
1437                data: b"exec".to_vec(),
1438            }),
1439        );
1440        let link = put(
1441            &store,
1442            &Object::Blob(Blob {
1443                data: b"target".to_vec(),
1444            }),
1445        );
1446        let sub = put(
1447            &store,
1448            &Object::Tree(Tree {
1449                entries: vec![TreeEntry {
1450                    name: b"run".to_vec(),
1451                    mode: EntryMode::Executable,
1452                    object_hash: exec,
1453                }],
1454            }),
1455        );
1456        let root = put(
1457            &store,
1458            &Object::Tree(Tree {
1459                entries: vec![
1460                    TreeEntry {
1461                        name: b"file.txt".to_vec(),
1462                        mode: EntryMode::Blob,
1463                        object_hash: file,
1464                    },
1465                    TreeEntry {
1466                        name: b"link".to_vec(),
1467                        mode: EntryMode::Symlink,
1468                        object_hash: link,
1469                    },
1470                    TreeEntry {
1471                        name: b"sub".to_vec(),
1472                        mode: EntryMode::Tree,
1473                        object_hash: sub,
1474                    },
1475                ],
1476            }),
1477        );
1478
1479        let idx = from_tree(&store, root).unwrap();
1480        assert_eq!(idx.entries.len(), 3);
1481        assert_eq!(idx.entries[0].path, "file.txt");
1482        assert_eq!(idx.entries[0].status, EntryStatus::Blob);
1483        assert_eq!(idx.entries[1].path, "link");
1484        assert_eq!(idx.entries[1].status, EntryStatus::Symlink);
1485        assert_eq!(idx.entries[2].path, "sub/run");
1486        assert_eq!(idx.entries[2].status, EntryStatus::Executable);
1487    }
1488
1489    #[test]
1490    fn from_tree_round_trips_through_worktree_builder() {
1491        use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1492        use crate::serialize;
1493        use crate::store::ObjectStore;
1494
1495        fn put(store: &ObjectStore, obj: &Object) -> Hash {
1496            let bytes = serialize::serialize(obj).unwrap();
1497            store.write(&bytes).unwrap()
1498        }
1499
1500        let dir = TempDir::new().unwrap();
1501        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1502        let blob = put(
1503            &store,
1504            &Object::Blob(Blob {
1505                data: b"content".to_vec(),
1506            }),
1507        );
1508        let tree = put(
1509            &store,
1510            &Object::Tree(Tree {
1511                entries: vec![TreeEntry {
1512                    name: b"a.txt".to_vec(),
1513                    mode: EntryMode::Blob,
1514                    object_hash: blob,
1515                }],
1516            }),
1517        );
1518
1519        let idx = from_tree(&store, tree).unwrap();
1520        let rebuilt = crate::worktree::build_tree_from_index(&store, &idx).unwrap();
1521        assert_eq!(rebuilt, tree);
1522    }
1523
1524    // ---- by_path (issue #708) --------------------------------------------
1525
1526    fn blob_entry(path: &str) -> IndexEntry {
1527        IndexEntry {
1528            path: path.to_string(),
1529            status: EntryStatus::Blob,
1530            object_hash: seed_hash(path),
1531            mtime_ns: 0,
1532            size: 0,
1533            ino: 0,
1534            ctime_ns: 0,
1535        }
1536    }
1537
1538    /// `upsert_entry` on a fresh path appends and registers it; `find_entry`
1539    /// then answers via the map, not a scan. Each call self-checks
1540    /// consistency in debug builds (see `debug_assert_consistent`), so this
1541    /// test alone exercises the map on every one of its mutations.
1542    #[test]
1543    fn upsert_entry_inserts_new_path() {
1544        let mut idx = Index::new();
1545        idx.upsert_entry(blob_entry("a.txt"));
1546        idx.upsert_entry(blob_entry("b.txt"));
1547        assert_eq!(idx.entries.len(), 2);
1548        assert_eq!(idx.find_entry("a.txt"), Some(0));
1549        assert_eq!(idx.find_entry("b.txt"), Some(1));
1550        assert_eq!(idx.find_entry("missing"), None);
1551    }
1552
1553    /// `upsert_entry` on an already-tracked path replaces in place — same
1554    /// position, no growth, and the map still resolves it.
1555    #[test]
1556    fn upsert_entry_replaces_existing_path() {
1557        let mut idx = Index::new();
1558        idx.upsert_entry(blob_entry("a.txt"));
1559        idx.upsert_entry(blob_entry("b.txt"));
1560        let mut replacement = blob_entry("a.txt");
1561        replacement.status = EntryStatus::Executable;
1562        idx.upsert_entry(replacement);
1563        assert_eq!(idx.entries.len(), 2, "replace must not grow the index");
1564        let pos = idx.find_entry("a.txt").unwrap();
1565        assert_eq!(idx.entries[pos].status, EntryStatus::Executable);
1566        // The unrelated entry's position is untouched.
1567        assert_eq!(idx.find_entry("b.txt"), Some(1));
1568    }
1569
1570    /// `remove_path` drops the entry and shifts later positions in the map
1571    /// to match the `Vec::remove` shift.
1572    #[test]
1573    fn remove_path_updates_positions_of_later_entries() {
1574        let mut idx = Index::new();
1575        idx.upsert_entry(blob_entry("a.txt"));
1576        idx.upsert_entry(blob_entry("b.txt"));
1577        idx.upsert_entry(blob_entry("c.txt"));
1578        let removed = idx.remove_path("a.txt").expect("a.txt was tracked");
1579        assert_eq!(removed.path, "a.txt");
1580        assert_eq!(idx.entries.len(), 2);
1581        assert_eq!(idx.find_entry("a.txt"), None);
1582        assert_eq!(idx.entries[idx.find_entry("b.txt").unwrap()].path, "b.txt");
1583        assert_eq!(idx.entries[idx.find_entry("c.txt").unwrap()].path, "c.txt");
1584        // Removing an absent path is a documented no-op.
1585        assert!(idx.remove_path("a.txt").is_none());
1586    }
1587
1588    /// `retain_entries` rebuilds the map so positions stay correct for
1589    /// every survivor, regardless of how many entries were dropped.
1590    #[test]
1591    fn retain_entries_rebuilds_positions() {
1592        let mut idx = Index::new();
1593        for p in ["a.txt", "b.txt", "c.txt", "d.txt"] {
1594            idx.upsert_entry(blob_entry(p));
1595        }
1596        idx.retain_entries(|e| e.path != "b.txt" && e.path != "c.txt");
1597        assert_eq!(idx.entries.len(), 2);
1598        assert_eq!(idx.entries[idx.find_entry("a.txt").unwrap()].path, "a.txt");
1599        assert_eq!(idx.entries[idx.find_entry("d.txt").unwrap()].path, "d.txt");
1600        assert_eq!(idx.find_entry("b.txt"), None);
1601        assert_eq!(idx.find_entry("c.txt"), None);
1602    }
1603
1604    /// The common `add -A` case: staging an unrelated file has no ancestor
1605    /// or descendant conflict, so nothing is removed.
1606    #[test]
1607    fn remove_directory_conflicts_is_noop_without_conflict() {
1608        let mut idx = Index::new();
1609        idx.upsert_entry(blob_entry("src/lib.rs"));
1610        idx.remove_directory_conflicts("src/main.rs");
1611        assert_eq!(idx.entries.len(), 1);
1612        assert!(idx.find_entry("src/lib.rs").is_some());
1613    }
1614
1615    /// Staging `a/b` when `a` is already tracked as a file evicts the
1616    /// ancestor entry.
1617    #[test]
1618    fn remove_directory_conflicts_evicts_tracked_ancestor() {
1619        let mut idx = Index::new();
1620        idx.upsert_entry(blob_entry("a"));
1621        idx.remove_directory_conflicts("a/b");
1622        assert_eq!(idx.find_entry("a"), None);
1623        assert_eq!(idx.entries.len(), 0);
1624    }
1625
1626    /// Staging `a` when `a/b` is already tracked as a file evicts the
1627    /// descendant entry, leaving unrelated entries alone.
1628    #[test]
1629    fn remove_directory_conflicts_evicts_tracked_descendant() {
1630        let mut idx = Index::new();
1631        idx.upsert_entry(blob_entry("a/b"));
1632        idx.upsert_entry(blob_entry("a/c"));
1633        idx.upsert_entry(blob_entry("unrelated.txt"));
1634        idx.remove_directory_conflicts("a");
1635        assert_eq!(idx.find_entry("a/b"), None);
1636        assert_eq!(idx.find_entry("a/c"), None);
1637        assert!(idx.find_entry("unrelated.txt").is_some());
1638        assert_eq!(idx.entries.len(), 1);
1639    }
1640
1641    /// An entry at exactly the staged path is left alone — the caller
1642    /// upserts it separately.
1643    #[test]
1644    fn remove_directory_conflicts_keeps_exact_match() {
1645        let mut idx = Index::new();
1646        idx.upsert_entry(blob_entry("a.txt"));
1647        idx.remove_directory_conflicts("a.txt");
1648        assert!(idx.find_entry("a.txt").is_some());
1649    }
1650
1651    /// `deserialize` (and therefore `read_index`) populates `by_path`
1652    /// without any caller having to call `upsert_entry` — `find_entry` on
1653    /// the result is immediately map-backed.
1654    #[test]
1655    fn deserialize_populates_path_index() {
1656        let mut built = Index::new();
1657        built.upsert_entry(blob_entry("a.txt"));
1658        built.upsert_entry(blob_entry("dir/b.txt"));
1659        let round_tripped = deserialize(&built.serialize()).unwrap();
1660        assert_eq!(round_tripped.find_entry("a.txt"), Some(0));
1661        assert_eq!(round_tripped.find_entry("dir/b.txt"), Some(1));
1662        assert!(round_tripped.tracks_path_or_descendant("dir"));
1663    }
1664
1665    /// Two indexes with identical entries compare equal regardless of
1666    /// whether their `by_path` cache happens to be populated — equality is
1667    /// about staged content, not internal cache state.
1668    #[test]
1669    fn equality_ignores_path_index_population() {
1670        let mut via_field_push = Index::new();
1671        via_field_push.entries.push(blob_entry("a.txt"));
1672        let mut via_upsert = Index::new();
1673        via_upsert.upsert_entry(blob_entry("a.txt"));
1674        assert_eq!(via_field_push, via_upsert);
1675    }
1676}