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][4B LE entry_count][entries...]
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;
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/// The current (and only supported) format version.
40pub const FORMAT_VERSION: u8 = 0x02;
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);
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
372    }
373}
374
375/// Yield every strict ancestor directory prefix of `path`, shallowest
376/// first — e.g. `"a/b/c.txt"` yields `"a"`, then `"a/b"` (never `path`
377/// itself). Used by [`Index::remove_directory_conflicts`] to check for a
378/// tracked ancestor-as-file in `O(depth)` instead of scanning the index.
379fn ancestor_prefixes(path: &str) -> impl Iterator<Item = &str> {
380    path.match_indices('/').map(move |(i, _)| &path[..i])
381}
382
383/// `true` if `path` is a strict descendant of `base` (`base` followed by a
384/// `/` and at least one more byte). Mirrors
385/// `mkit_cli::commands::index_path_descends_from` — duplicated here rather
386/// than shared across the crate boundary, since `mkit-core` cannot depend
387/// on `mkit-cli`.
388fn path_descends_from(path: &str, base: &str) -> bool {
389    path.len() > base.len()
390        && path.starts_with(base)
391        && path.as_bytes().get(base.len()) == Some(&b'/')
392}
393
394/// Errors returned by the index subsystem.
395#[derive(Debug, thiserror::Error)]
396pub enum IndexError {
397    /// Magic bytes were not `"MKIX"`.
398    #[error("index file has wrong magic (expected MKIX)")]
399    BadMagic,
400    /// `version` byte was not the current `FORMAT_VERSION`.
401    #[error("unsupported index version: {0:#x}")]
402    UnsupportedVersion(u8),
403    /// Status byte was outside the documented {0x00..=0x04} range.
404    #[error("index entry has unknown status byte {0:#x}")]
405    BadStatus(u8),
406    /// Truncated or otherwise malformed entry.
407    #[error("index file is corrupt")]
408    Corrupt,
409    /// File exceeded [`MAX_INDEX_BYTES`].
410    #[error("index file too large (>{MAX_INDEX_BYTES} bytes)")]
411    TooLarge,
412    /// Path failed [`validate_index_path`].
413    #[error("invalid index path '{0}'")]
414    InvalidPath(String),
415    /// Path appeared more than once in the same index.
416    #[error("duplicate index path '{0}'")]
417    DuplicatePath(String),
418    /// A `Removed` entry carried a nonzero `object_hash` (SPEC-INDEX §3
419    /// requires the all-zero hash for removals).
420    #[error("removed index entry '{0}' has nonzero object_hash")]
421    RemovedHasHash(String),
422    /// Path UTF-8 decoding failed.
423    #[error("index path is not valid UTF-8")]
424    InvalidPathEncoding,
425    /// Underlying I/O failure.
426    #[error(transparent)]
427    Io(#[from] io::Error),
428    /// Object store lookup/decoding failed while deriving an index from a tree.
429    #[error(transparent)]
430    Store(#[from] StoreError),
431    /// A tree walk found a non-tree object where a tree hash was expected.
432    #[error("object is not a tree")]
433    NotTree,
434    /// A tree walk exceeded [`MAX_TREE_DEPTH`] nesting levels — likely a
435    /// crafted untrusted repo trying to overflow the native stack.
436    #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
437    TreeTooDeep,
438}
439
440/// Result alias used throughout this module.
441pub type IndexResult<T> = Result<T, IndexError>;
442
443/// Deserialise bytes into an [`Index`].
444///
445/// # Errors
446/// See [`IndexError`].
447///
448/// # Panics
449/// Panics only if internal fixed-width slicing is wrong, which is
450/// impossible by construction (lengths are bounds-checked first).
451pub fn deserialize(data: &[u8]) -> IndexResult<Index> {
452    if data.len() < 9 {
453        return Err(IndexError::Corrupt);
454    }
455    if data[0..4] != MAGIC {
456        return Err(IndexError::BadMagic);
457    }
458    let version = data[4];
459    if version != FORMAT_VERSION {
460        return Err(IndexError::UnsupportedVersion(version));
461    }
462    // Entries carry mtime_ns(8) + size(8) + ino(8) + ctime_ns(8) before
463    // path_len.
464    let stat_cache_len: usize = 32;
465    // Fixed bytes per entry: status(1) + hash(32) + stat cache + path_len(2).
466    let min_entry_len = 1 + HASH_LEN + stat_cache_len + 2;
467    let count = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
468    // Reject an attacker-supplied `count` that is impossible given the
469    // remaining bytes. The minimum wire-length of an entry is 67 bytes
470    // (empty path). Without this up-front check the loop would walk
471    // `count` iterations before failing — trivially triggered with a
472    // 9-byte buffer declaring `count = u32::MAX`.
473    // Mirrors the same up-front bound used in `serialize.rs`.
474    if (count as u64).saturating_mul(min_entry_len as u64) > data.len() as u64 {
475        return Err(IndexError::Corrupt);
476    }
477    let mut entries = Vec::with_capacity(count.min(1024)); // bound initial alloc
478    let mut seen_paths = std::collections::HashSet::with_capacity(count.min(1024));
479    let mut offset = 9usize;
480    for _ in 0..count {
481        if offset + min_entry_len > data.len() {
482            return Err(IndexError::Corrupt);
483        }
484        let status =
485            EntryStatus::from_byte(data[offset]).ok_or(IndexError::BadStatus(data[offset]))?;
486        offset += 1;
487        let mut object_hash = [0u8; HASH_LEN];
488        object_hash.copy_from_slice(&data[offset..offset + HASH_LEN]);
489        offset += HASH_LEN;
490        let (mtime_ns, size, ino, ctime_ns) = {
491            let mut next_u64 = || {
492                let v = u64::from_le_bytes(data[offset..offset + 8].try_into().expect("8 bytes"));
493                offset += 8;
494                v
495            };
496            (next_u64(), next_u64(), next_u64(), next_u64())
497        };
498        let path_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
499        offset += 2;
500        if path_len > MAX_PATH_LEN {
501            return Err(IndexError::Corrupt);
502        }
503        if offset + path_len > data.len() {
504            return Err(IndexError::Corrupt);
505        }
506        let path_bytes = &data[offset..offset + path_len];
507        let path = core::str::from_utf8(path_bytes)
508            .map_err(|_| IndexError::InvalidPathEncoding)?
509            .to_string();
510        offset += path_len;
511        if !validate_index_path(&path) {
512            return Err(IndexError::InvalidPath(path));
513        }
514        if !seen_paths.insert(path.clone()) {
515            return Err(IndexError::DuplicatePath(path));
516        }
517        if status == EntryStatus::Removed && object_hash != hash::ZERO {
518            return Err(IndexError::RemovedHasHash(path));
519        }
520        entries.push(IndexEntry {
521            path,
522            status,
523            object_hash,
524            mtime_ns,
525            size,
526            ino,
527            ctime_ns,
528        });
529    }
530    if offset != data.len() {
531        return Err(IndexError::Corrupt);
532    }
533    Ok(Index::from_entries(entries))
534}
535
536/// Read this worktree's staging index. Returns an empty index if the
537/// file is absent or zero-length. The index is per-worktree state —
538/// see [`crate::layout`].
539pub fn read_index(layout: &RepoLayout) -> IndexResult<Index> {
540    let path = layout.index_file();
541    let meta = match fs::metadata(&path) {
542        Ok(m) => m,
543        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Index::new()),
544        Err(e) => return Err(IndexError::Io(e)),
545    };
546    if meta.len() == 0 {
547        return Ok(Index::new());
548    }
549    if meta.len() > MAX_INDEX_BYTES {
550        return Err(IndexError::TooLarge);
551    }
552    let bytes = fs::read(&path)?;
553    let mut idx = deserialize(&bytes)?;
554    // git's racy-clean rule, applied at read time: an entry whose
555    // cached mtime is not safely OLDER than the index file itself may
556    // have been modified after hashing without its stat changing —
557    // within the filesystem timestamp granularity the modification is
558    // invisible to stat. Treat such entries as uncached (zero
559    // sentinel) so callers re-hash them; the next index write (whose
560    // file mtime is then newer) heals the cache.
561    // Same conversion (incl. 0-sentinel + saturation semantics) as the
562    // entry mtimes it is compared against — one implementation only.
563    let index_mtime_ns = crate::worktree::mtime_nanos(&meta);
564    // Window sizing, like git's USE_NSEC — but judged PER ENTRY: the
565    // tight 10ms window is only safe when BOTH the index file's mtime
566    // and the entry's recorded worktree mtime show sub-second
567    // precision. A worktree file whose mtime is whole-second (vfat/
568    // SMB/NFS mounts, tar/touch -t/rsync-truncated timestamps) could
569    // be rewritten within its coarse tick without the stat changing,
570    // so such entries keep the conservative 1s window.
571    let index_ns_precise = !index_mtime_ns.is_multiple_of(1_000_000_000);
572    for e in &mut idx.entries {
573        if e.mtime_ns == 0 {
574            continue;
575        }
576        let window = if index_ns_precise && !e.mtime_ns.is_multiple_of(1_000_000_000) {
577            RACY_WINDOW_NS / 100
578        } else {
579            RACY_WINDOW_NS
580        };
581        if e.mtime_ns >= index_mtime_ns.saturating_sub(window) {
582            e.mtime_ns = 0;
583            e.size = 0;
584            e.ino = 0;
585            e.ctime_ns = 0;
586        }
587    }
588    Ok(idx)
589}
590
591/// The racy-clean window: an entry whose cached mtime is within this
592/// span of the index file's own mtime may have been modified after
593/// hashing without its stat changing (filesystem timestamp granularity
594/// can be as coarse as 1s), so its cache cannot be trusted. One second
595/// is the conservative bound git uses for second-granularity
596/// filesystems.
597const RACY_WINDOW_NS: u64 = 1_000_000_000;
598
599/// Write this worktree's staging index atomically. The containing
600/// directory is created if absent.
601///
602/// Stat-cache fields are written verbatim; the racy-clean rule is
603/// applied at READ time against the index file's own mtime (see
604/// [`read_index`]). Note a read-modify-write command that loads a
605/// racy-marked entry persists the zeroed cache for it — sound (zero
606/// always re-hashes) and healed by the next add/status touching the
607/// path; only the racy window's worth of entries is affected.
608pub fn write_index(layout: &RepoLayout, idx: &Index) -> IndexResult<()> {
609    let path = layout.index_file();
610    write_atomic(&path, &idx.serialize(), true)?;
611    Ok(())
612}
613
614/// Materialize a staging index from a committed tree.
615///
616/// This is used after commands that move `HEAD` and restore the
617/// worktree so the index keeps matching the new commit snapshot. Tree
618/// entries are recursively flattened into leaf paths; removed entries
619/// are not represented because a committed tree has no tombstones.
620///
621/// # Errors
622/// Propagates object-store errors and returns [`IndexError::NotTree`]
623/// if `tree_hash` does not point at a tree object.
624pub fn from_tree(store: &ObjectStore, tree_hash: Hash) -> IndexResult<Index> {
625    let mut entries = Vec::new();
626    push_tree_entries(store, tree_hash, "", &mut entries, 0)?;
627    Ok(Index::from_entries(entries))
628}
629
630fn push_tree_entries(
631    store: &ObjectStore,
632    tree_hash: Hash,
633    prefix: &str,
634    entries: &mut Vec<IndexEntry>,
635    depth: usize,
636) -> IndexResult<()> {
637    if depth > MAX_TREE_DEPTH {
638        return Err(IndexError::TreeTooDeep);
639    }
640    let Object::Tree(tree) = store.read_object(&tree_hash)? else {
641        return Err(IndexError::NotTree);
642    };
643    for entry in tree.entries {
644        let name = String::from_utf8(entry.name).map_err(|_| IndexError::InvalidPathEncoding)?;
645        let path = if prefix.is_empty() {
646            name
647        } else {
648            format!("{prefix}/{name}")
649        };
650        match entry.mode {
651            EntryMode::Tree => {
652                push_tree_entries(store, entry.object_hash, &path, entries, depth + 1)?;
653            }
654            EntryMode::Blob | EntryMode::Executable | EntryMode::Symlink => {
655                if !validate_index_path(&path) {
656                    return Err(IndexError::InvalidPath(path));
657                }
658                let status = match entry.mode {
659                    EntryMode::Blob => EntryStatus::Blob,
660                    EntryMode::Executable => EntryStatus::Executable,
661                    EntryMode::Symlink => EntryStatus::Symlink,
662                    EntryMode::Tree => unreachable!("handled above"),
663                };
664                entries.push(IndexEntry {
665                    path,
666                    status,
667                    object_hash: entry.object_hash,
668                    // A tree-derived entry has no observed worktree
669                    // stat — zero sentinel means "re-hash to compare".
670                    mtime_ns: 0,
671                    size: 0,
672                    ino: 0,
673                    ctime_ns: 0,
674                });
675            }
676        }
677    }
678    Ok(())
679}
680
681/// Compute the absolute path of this worktree's index file.
682#[must_use]
683pub fn index_path(layout: &RepoLayout) -> PathBuf {
684    layout.index_file()
685}
686
687/// Validate a staged path: non-empty, relative, no traversal, no NUL,
688/// no backslash, never under `.mkit/` or `.git/`.
689#[must_use]
690pub fn validate_index_path(path: &str) -> bool {
691    if path.is_empty() {
692        return false;
693    }
694    if path.starts_with('/') {
695        return false;
696    }
697    if path.len() > MAX_PATH_LEN {
698        return false;
699    }
700    if path == ".mkit" || path == ".git" {
701        return false;
702    }
703    if path.starts_with(".mkit/") || path.starts_with(".git/") {
704        return false;
705    }
706    for part in path.split('/') {
707        if part.is_empty() {
708            return false;
709        }
710        if part == "." || part == ".." {
711            return false;
712        }
713        for &c in part.as_bytes() {
714            if c == 0 || c == b'\\' {
715                return false;
716            }
717        }
718    }
719    true
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::hash;
726    use tempfile::TempDir;
727
728    fn seed_hash(s: &str) -> Hash {
729        hash::hash(s.as_bytes())
730    }
731
732    #[test]
733    fn empty_index_round_trip() {
734        let idx = Index::new();
735        let bytes = idx.serialize();
736        // 4 magic + 1 version + 4 count = 9 bytes.
737        assert_eq!(bytes.len(), 9);
738        assert_eq!(&bytes[0..4], &MAGIC);
739        assert_eq!(bytes[4], FORMAT_VERSION);
740        assert_eq!(&bytes[5..9], &0u32.to_le_bytes());
741        let parsed = deserialize(&bytes).unwrap();
742        assert_eq!(parsed, idx);
743    }
744
745    // ---- stat cache -----------------------------------------------------
746
747    /// Pinned vector: header(9) + status(1) + hash(32) +
748    /// `mtime_ns`(8) + `size`(8) + `ino`(8) + `ctime_ns`(8) +
749    /// `path_len`(2) + "hello.txt"(9) = 85 bytes.
750    #[test]
751    fn single_entry_pinned_bytes() {
752        let h = seed_hash("hello");
753        let idx = Index::from_entries(vec![IndexEntry {
754            path: "hello.txt".to_string(),
755            status: EntryStatus::Blob,
756            object_hash: h,
757            mtime_ns: 0x0102_0304_0506_0708,
758            size: 11,
759            ino: 0x0A0B_0C0D_0E0F_1011,
760            ctime_ns: 0x1112_1314_1516_1718,
761        }]);
762        let bytes = idx.serialize();
763        assert_eq!(bytes.len(), 85);
764        let mut expected = Vec::new();
765        expected.extend_from_slice(b"MKIX");
766        expected.push(0x02); // version
767        expected.extend_from_slice(&1u32.to_le_bytes());
768        expected.push(0x01); // Blob
769        expected.extend_from_slice(&h);
770        expected.extend_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes());
771        expected.extend_from_slice(&11u64.to_le_bytes());
772        expected.extend_from_slice(&0x0A0B_0C0D_0E0F_1011u64.to_le_bytes());
773        expected.extend_from_slice(&0x1112_1314_1516_1718u64.to_le_bytes());
774        expected.extend_from_slice(&9u16.to_le_bytes());
775        expected.extend_from_slice(b"hello.txt");
776        assert_eq!(bytes, expected, "byte layout is pinned");
777        assert_eq!(deserialize(&bytes).unwrap(), idx);
778    }
779
780    #[test]
781    fn rejects_count_overflow_at_min_entry_bytes() {
782        // 9-byte header declaring u32::MAX entries: the minimum entry is
783        // 67 bytes, so this must fail fast, before looping.
784        let mut bytes = Vec::new();
785        bytes.extend_from_slice(b"MKIX");
786        bytes.push(0x02);
787        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
788        assert!(matches!(deserialize(&bytes), Err(IndexError::Corrupt)));
789        // One entry declared, only 60 bytes of body: still corrupt.
790        let mut short = Vec::new();
791        short.extend_from_slice(b"MKIX");
792        short.push(0x02);
793        short.extend_from_slice(&1u32.to_le_bytes());
794        short.extend_from_slice(&[0u8; 60]);
795        assert!(matches!(deserialize(&short), Err(IndexError::Corrupt)));
796    }
797
798    #[test]
799    fn rejects_unknown_version_0x03() {
800        let mut bytes = Vec::new();
801        bytes.extend_from_slice(b"MKIX");
802        bytes.push(0x03);
803        bytes.extend_from_slice(&0u32.to_le_bytes());
804        assert!(matches!(
805            deserialize(&bytes),
806            Err(IndexError::UnsupportedVersion(0x03))
807        ));
808    }
809
810    /// The old pre-stat-cache format (version `0x01`) is rejected like any
811    /// other unsupported version — mkit does not carry dual-version
812    /// read-compat for its local, advisory index file (SPEC-CONVENTIONS
813    /// §4: no version-suffixed compatibility eras).
814    #[test]
815    fn rejects_old_version_0x01() {
816        let mut bytes = Vec::new();
817        bytes.extend_from_slice(b"MKIX");
818        bytes.push(0x01);
819        bytes.extend_from_slice(&0u32.to_le_bytes());
820        assert!(matches!(
821            deserialize(&bytes),
822            Err(IndexError::UnsupportedVersion(0x01))
823        ));
824    }
825
826    /// git's racy-clean rule: an entry whose mtime is within the
827    /// filesystem-timestamp granularity of the index file's mtime may
828    /// have been modified after hashing without the stat changing —
829    /// its cache must be ignored on read so the caller re-hashes.
830    #[test]
831    fn read_index_invalidates_racy_entries() {
832        let dir = TempDir::new().unwrap();
833        let layout = RepoLayout::single(dir.path());
834        let now_ns = u64::try_from(
835            std::time::SystemTime::now()
836                .duration_since(std::time::UNIX_EPOCH)
837                .unwrap()
838                .as_nanos(),
839        )
840        .unwrap();
841        let idx = Index::from_entries(vec![
842            IndexEntry {
843                path: "racy.txt".to_string(),
844                status: EntryStatus::Blob,
845                object_hash: seed_hash("racy"),
846                mtime_ns: now_ns,
847                size: 4,
848                ino: 0,
849                ctime_ns: 0,
850            },
851            IndexEntry {
852                path: "settled.txt".to_string(),
853                status: EntryStatus::Blob,
854                object_hash: seed_hash("settled"),
855                mtime_ns: now_ns - 10_000_000_000, // 10s ago
856                size: 7,
857                ino: 0,
858                ctime_ns: 0,
859            },
860        ]);
861        write_index(&layout, &idx).unwrap();
862        // Pin the index FILE's mtime to exactly the racy entry's time so
863        // the test is deterministic regardless of scheduling delays and
864        // the granularity-derived window size: an entry whose mtime
865        // equals the index mtime is racy under any window.
866        let f = fs::File::options()
867            .write(true)
868            .open(index_path(&layout))
869            .unwrap();
870        f.set_times(
871            fs::FileTimes::new()
872                .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_nanos(now_ns)),
873        )
874        .unwrap();
875        drop(f);
876        let read = read_index(&layout).unwrap();
877        let racy = &read.entries[read.find_entry("racy.txt").unwrap()];
878        let settled = &read.entries[read.find_entry("settled.txt").unwrap()];
879        assert_eq!(
880            racy.mtime_ns, 0,
881            "an entry touched within the racy window must lose its cache"
882        );
883        assert_eq!(racy.size, 0);
884        assert_eq!(settled.mtime_ns, now_ns - 10_000_000_000);
885        assert_eq!(settled.size, 7);
886    }
887
888    /// A whole-second entry mtime (vfat/SMB/tar-truncated timestamps)
889    /// must keep the conservative 1s racy window even when the index
890    /// file itself has nanosecond precision — the file could be
891    /// rewritten within its coarse tick without the stat changing.
892    #[test]
893    fn coarse_entry_mtime_keeps_one_second_window() {
894        let dir = TempDir::new().unwrap();
895        let layout = RepoLayout::single(dir.path());
896        let base_ns: u64 = 1_700_000_000_000_000_000; // whole-second tick
897        let idx = Index::from_entries(vec![
898            IndexEntry {
899                path: "coarse.txt".to_string(),
900                status: EntryStatus::Blob,
901                object_hash: seed_hash("coarse"),
902                // 500ms before the index mtime, WHOLE-second value:
903                // inside the 1s window, outside the 10ms one.
904                mtime_ns: base_ns - 1_000_000_000,
905                size: 4,
906                ino: 0,
907                ctime_ns: 0,
908            },
909            IndexEntry {
910                path: "precise.txt".to_string(),
911                status: EntryStatus::Blob,
912                object_hash: seed_hash("precise"),
913                // Same age but ns-precise: the 10ms window applies
914                // and it is safely older than the floor.
915                mtime_ns: base_ns - 1_000_000_000 + 123,
916                size: 7,
917                ino: 0,
918                ctime_ns: 0,
919            },
920        ]);
921        write_index(&layout, &idx).unwrap();
922        // Index file mtime: ns-precise, 500ms after the coarse entry.
923        let f = fs::File::options()
924            .write(true)
925            .open(index_path(&layout))
926            .unwrap();
927        f.set_times(fs::FileTimes::new().set_modified(
928            std::time::UNIX_EPOCH + std::time::Duration::from_nanos(base_ns - 500_000_000 + 777),
929        ))
930        .unwrap();
931        drop(f);
932
933        let read = read_index(&layout).unwrap();
934        let coarse = &read.entries[read.find_entry("coarse.txt").unwrap()];
935        let precise = &read.entries[read.find_entry("precise.txt").unwrap()];
936        assert_eq!(
937            coarse.mtime_ns, 0,
938            "coarse-mtime entry within 1s of the index write must be racy"
939        );
940        assert_ne!(
941            precise.mtime_ns, 0,
942            "ns-precise entry outside the 10ms window keeps its cache"
943        );
944    }
945
946    #[test]
947    fn tracks_path_or_descendant_matches_self_and_ancestors() {
948        let mut idx = Index::new();
949        idx.upsert_entry(IndexEntry {
950            path: "src/lib.rs".to_string(),
951            status: EntryStatus::Blob,
952            object_hash: seed_hash("lib"),
953            mtime_ns: 0,
954            size: 0,
955            ino: 0,
956            ctime_ns: 0,
957        });
958        idx.upsert_entry(IndexEntry {
959            path: "removed.txt".to_string(),
960            status: EntryStatus::Removed,
961            object_hash: hash::ZERO,
962            mtime_ns: 0,
963            size: 0,
964            ino: 0,
965            ctime_ns: 0,
966        });
967        // Exact tracked path and its ancestor directory both match.
968        assert!(idx.tracks_path_or_descendant("src/lib.rs"));
969        assert!(idx.tracks_path_or_descendant("src"));
970        // A prefix that is not a path-segment boundary does not match.
971        assert!(!idx.tracks_path_or_descendant("sr"));
972        // Unrelated and removed-only paths do not match.
973        assert!(!idx.tracks_path_or_descendant("docs"));
974        assert!(!idx.tracks_path_or_descendant("removed.txt"));
975    }
976
977    #[test]
978    fn has_tracked_file_at_exact_only_and_not_removed() {
979        let mut idx = Index::new();
980        idx.upsert_entry(IndexEntry {
981            path: "f".to_string(),
982            status: EntryStatus::Blob,
983            object_hash: seed_hash("f"),
984            mtime_ns: 0,
985            size: 0,
986            ino: 0,
987            ctime_ns: 0,
988        });
989        idx.upsert_entry(IndexEntry {
990            path: "gone".to_string(),
991            status: EntryStatus::Removed,
992            object_hash: hash::ZERO,
993            mtime_ns: 0,
994            size: 0,
995            ino: 0,
996            ctime_ns: 0,
997        });
998        // Exact tracked file matches.
999        assert!(idx.has_tracked_file_at("f"));
1000        // Unlike `tracks_path_or_descendant`, an ancestor directory does NOT
1001        // match — only an exact tracked leaf does (the collision predicate).
1002        idx.upsert_entry(IndexEntry {
1003            path: "dir/inner.txt".to_string(),
1004            status: EntryStatus::Blob,
1005            object_hash: seed_hash("inner"),
1006            mtime_ns: 0,
1007            size: 0,
1008            ino: 0,
1009            ctime_ns: 0,
1010        });
1011        assert!(!idx.has_tracked_file_at("dir"));
1012        assert!(idx.has_tracked_file_at("dir/inner.txt"));
1013        // A `Removed` tombstone must NOT suppress — the path is no longer
1014        // tracked, so a replacement at that path is genuinely untracked.
1015        assert!(!idx.has_tracked_file_at("gone"));
1016        // Unrelated path.
1017        assert!(!idx.has_tracked_file_at("other"));
1018    }
1019
1020    #[test]
1021    fn multi_entry_round_trip_with_all_statuses() {
1022        let mut idx = Index::new();
1023        idx.entries.push(IndexEntry {
1024            path: "a.txt".into(),
1025            status: EntryStatus::Blob,
1026            object_hash: seed_hash("a"),
1027            mtime_ns: 0,
1028            size: 0,
1029            ino: 0,
1030            ctime_ns: 0,
1031        });
1032        idx.entries.push(IndexEntry {
1033            path: "b/sub".into(),
1034            status: EntryStatus::Tree,
1035            object_hash: seed_hash("b"),
1036            mtime_ns: 0,
1037            size: 0,
1038            ino: 0,
1039            ctime_ns: 0,
1040        });
1041        idx.entries.push(IndexEntry {
1042            path: "c.link".into(),
1043            status: EntryStatus::Symlink,
1044            object_hash: seed_hash("c"),
1045            mtime_ns: 0,
1046            size: 0,
1047            ino: 0,
1048            ctime_ns: 0,
1049        });
1050        idx.entries.push(IndexEntry {
1051            path: "scripts/build".into(),
1052            status: EntryStatus::Executable,
1053            object_hash: seed_hash("d"),
1054            mtime_ns: 0,
1055            size: 0,
1056            ino: 0,
1057            ctime_ns: 0,
1058        });
1059        idx.entries.push(IndexEntry {
1060            path: "old.txt".into(),
1061            status: EntryStatus::Removed,
1062            object_hash: [0u8; HASH_LEN],
1063            mtime_ns: 0,
1064            size: 0,
1065            ino: 0,
1066            ctime_ns: 0,
1067        });
1068        let bytes = idx.serialize();
1069        let parsed = deserialize(&bytes).unwrap();
1070        assert_eq!(parsed, idx);
1071    }
1072
1073    #[test]
1074    fn rejects_bad_magic() {
1075        let mut bytes = Index::new().serialize();
1076        bytes[0] = b'X';
1077        let err = deserialize(&bytes).unwrap_err();
1078        assert!(matches!(err, IndexError::BadMagic));
1079    }
1080
1081    #[test]
1082    fn rejects_zmix_magic_explicitly() {
1083        // SPEC-INDEX §5: readers MUST reject `"ZMIX"`-prefixed files. We
1084        // construct the rejected magic as ASCII bytes here rather than
1085        // embedding the wrong literal string.
1086        let bytes = [
1087            0x5A,
1088            0x4D,
1089            0x49,
1090            0x58, // "ZMIX"
1091            FORMAT_VERSION,
1092            0,
1093            0,
1094            0,
1095            0,
1096        ];
1097        let err = deserialize(&bytes).unwrap_err();
1098        assert!(matches!(err, IndexError::BadMagic));
1099    }
1100
1101    #[test]
1102    fn rejects_unsupported_version() {
1103        let mut bytes = Index::new().serialize();
1104        bytes[4] = 0xFF;
1105        let err = deserialize(&bytes).unwrap_err();
1106        assert!(matches!(err, IndexError::UnsupportedVersion(0xFF)));
1107    }
1108
1109    #[test]
1110    fn rejects_truncated_header() {
1111        let err = deserialize(b"MKIX").unwrap_err();
1112        assert!(matches!(err, IndexError::Corrupt));
1113    }
1114
1115    #[test]
1116    fn rejects_truncated_entry() {
1117        let mut idx = Index::new();
1118        idx.entries.push(IndexEntry {
1119            path: "a".into(),
1120            status: EntryStatus::Blob,
1121            object_hash: seed_hash("a"),
1122            mtime_ns: 0,
1123            size: 0,
1124            ino: 0,
1125            ctime_ns: 0,
1126        });
1127        let mut bytes = idx.serialize();
1128        bytes.truncate(bytes.len() - 1); // drop the trailing path byte
1129        let err = deserialize(&bytes).unwrap_err();
1130        assert!(matches!(err, IndexError::Corrupt));
1131    }
1132
1133    #[test]
1134    fn rejects_trailing_bytes_after_declared_entries() {
1135        let mut idx = Index::new();
1136        idx.entries.push(IndexEntry {
1137            path: "a".into(),
1138            status: EntryStatus::Blob,
1139            object_hash: seed_hash("a"),
1140            mtime_ns: 0,
1141            size: 0,
1142            ino: 0,
1143            ctime_ns: 0,
1144        });
1145        let mut bytes = idx.serialize();
1146        bytes.extend_from_slice(b"junk");
1147        let err = deserialize(&bytes).unwrap_err();
1148        assert!(matches!(err, IndexError::Corrupt));
1149    }
1150
1151    #[test]
1152    fn rejects_invalid_path_on_deserialize() {
1153        let mut bytes = Vec::new();
1154        bytes.extend_from_slice(&MAGIC);
1155        bytes.push(FORMAT_VERSION);
1156        bytes.extend_from_slice(&1u32.to_le_bytes());
1157        bytes.push(EntryStatus::Blob as u8);
1158        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1159        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1160        let path = b"../escape";
1161        let path_len = u16::try_from(path.len()).unwrap();
1162        bytes.extend_from_slice(&path_len.to_le_bytes());
1163        bytes.extend_from_slice(path);
1164        let err = deserialize(&bytes).unwrap_err();
1165        assert!(matches!(err, IndexError::InvalidPath(path) if path == "../escape"));
1166    }
1167
1168    #[test]
1169    fn rejects_duplicate_paths_on_deserialize() {
1170        let mut idx = Index::new();
1171        idx.entries.push(IndexEntry {
1172            path: "same.txt".into(),
1173            status: EntryStatus::Blob,
1174            object_hash: seed_hash("a"),
1175            mtime_ns: 0,
1176            size: 0,
1177            ino: 0,
1178            ctime_ns: 0,
1179        });
1180        idx.entries.push(IndexEntry {
1181            path: "same.txt".into(),
1182            status: EntryStatus::Executable,
1183            object_hash: seed_hash("b"),
1184            mtime_ns: 0,
1185            size: 0,
1186            ino: 0,
1187            ctime_ns: 0,
1188        });
1189        let err = deserialize(&idx.serialize()).unwrap_err();
1190        assert!(matches!(err, IndexError::DuplicatePath(path) if path == "same.txt"));
1191    }
1192
1193    #[test]
1194    fn rejects_path_len_overflow() {
1195        // Hand-roll: path_len = 1000 but only 1 byte available.
1196        let mut bytes = Vec::new();
1197        bytes.extend_from_slice(&MAGIC);
1198        bytes.push(FORMAT_VERSION);
1199        bytes.extend_from_slice(&1u32.to_le_bytes());
1200        bytes.push(EntryStatus::Blob as u8);
1201        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1202        bytes.extend_from_slice(&1000u16.to_le_bytes());
1203        bytes.push(b'a');
1204        let err = deserialize(&bytes).unwrap_err();
1205        assert!(matches!(err, IndexError::Corrupt));
1206    }
1207
1208    #[test]
1209    fn rejects_unknown_status_byte() {
1210        let mut bytes = Vec::new();
1211        bytes.extend_from_slice(&MAGIC);
1212        bytes.push(FORMAT_VERSION);
1213        bytes.extend_from_slice(&1u32.to_le_bytes());
1214        bytes.push(0x77); // bogus status
1215        bytes.extend_from_slice(&[0u8; HASH_LEN]);
1216        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1217        bytes.extend_from_slice(&0u16.to_le_bytes());
1218        let err = deserialize(&bytes).unwrap_err();
1219        assert!(matches!(err, IndexError::BadStatus(0x77)));
1220    }
1221
1222    #[test]
1223    fn rejects_removed_entry_with_nonzero_hash() {
1224        // Hand-roll a Removed (0x00) entry carrying a nonzero object_hash —
1225        // SPEC-INDEX §3 requires the all-zero hash for removals.
1226        let mut bytes = Vec::new();
1227        bytes.extend_from_slice(&MAGIC);
1228        bytes.push(FORMAT_VERSION);
1229        bytes.extend_from_slice(&1u32.to_le_bytes());
1230        bytes.push(EntryStatus::Removed as u8);
1231        bytes.extend_from_slice(&seed_hash("nonzero")); // MUST be all-zero
1232        bytes.extend_from_slice(&[0u8; 32]); // stat cache
1233        let path = b"removed.txt";
1234        bytes.extend_from_slice(&11u16.to_le_bytes());
1235        bytes.extend_from_slice(path);
1236        let err = deserialize(&bytes).unwrap_err();
1237        assert!(matches!(err, IndexError::RemovedHasHash(p) if p == "removed.txt"));
1238    }
1239
1240    #[test]
1241    fn removed_entry_with_zero_hash_round_trips() {
1242        let mut idx = Index::new();
1243        idx.entries.push(IndexEntry {
1244            path: "gone.txt".into(),
1245            status: EntryStatus::Removed,
1246            object_hash: hash::ZERO,
1247            mtime_ns: 0,
1248            size: 0,
1249            ino: 0,
1250            ctime_ns: 0,
1251        });
1252        let round_tripped = deserialize(&idx.serialize()).unwrap();
1253        assert_eq!(round_tripped.entries[0].status, EntryStatus::Removed);
1254        assert_eq!(round_tripped.entries[0].object_hash, hash::ZERO);
1255    }
1256
1257    #[test]
1258    fn write_and_read_round_trip_via_disk() {
1259        let dir = TempDir::new().unwrap();
1260        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1261        let mut idx = Index::new();
1262        idx.entries.push(IndexEntry {
1263            path: "test.txt".into(),
1264            status: EntryStatus::Blob,
1265            object_hash: seed_hash("c"),
1266            mtime_ns: 0,
1267            size: 0,
1268            ino: 0,
1269            ctime_ns: 0,
1270        });
1271        let layout = RepoLayout::single(dir.path());
1272        write_index(&layout, &idx).unwrap();
1273        let read = read_index(&layout).unwrap();
1274        assert_eq!(read, idx);
1275    }
1276
1277    #[test]
1278    fn read_missing_file_returns_empty_index() {
1279        let dir = TempDir::new().unwrap();
1280        let idx = read_index(&RepoLayout::single(dir.path())).unwrap();
1281        assert!(idx.entries.is_empty());
1282    }
1283
1284    #[test]
1285    fn read_zero_length_file_returns_empty_index() {
1286        let dir = TempDir::new().unwrap();
1287        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1288        fs::write(dir.path().join(INDEX_FILE), b"").unwrap();
1289        let idx = read_index(&RepoLayout::single(dir.path())).unwrap();
1290        assert!(idx.entries.is_empty());
1291    }
1292
1293    #[test]
1294    fn read_oversize_file_rejected() {
1295        let dir = TempDir::new().unwrap();
1296        fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1297        let path = dir.path().join(INDEX_FILE);
1298        // Sparse-extend beyond the cap; allocates effectively no blocks.
1299        let f = fs::OpenOptions::new()
1300            .write(true)
1301            .create(true)
1302            .truncate(true)
1303            .open(&path)
1304            .unwrap();
1305        f.set_len(MAX_INDEX_BYTES + 1).unwrap();
1306        drop(f);
1307        let err = read_index(&RepoLayout::single(dir.path())).unwrap_err();
1308        assert!(matches!(err, IndexError::TooLarge));
1309    }
1310
1311    #[test]
1312    fn staged_count_excludes_removed() {
1313        let mut idx = Index::new();
1314        idx.entries.push(IndexEntry {
1315            path: "a".into(),
1316            status: EntryStatus::Blob,
1317            object_hash: seed_hash("a"),
1318            mtime_ns: 0,
1319            size: 0,
1320            ino: 0,
1321            ctime_ns: 0,
1322        });
1323        idx.entries.push(IndexEntry {
1324            path: "b".into(),
1325            status: EntryStatus::Removed,
1326            object_hash: [0u8; HASH_LEN],
1327            mtime_ns: 0,
1328            size: 0,
1329            ino: 0,
1330            ctime_ns: 0,
1331        });
1332        idx.entries.push(IndexEntry {
1333            path: "c".into(),
1334            status: EntryStatus::Blob,
1335            object_hash: seed_hash("c"),
1336            mtime_ns: 0,
1337            size: 0,
1338            ino: 0,
1339            ctime_ns: 0,
1340        });
1341        assert_eq!(idx.staged_count(), 2);
1342    }
1343
1344    #[test]
1345    fn rejects_bogus_huge_count_before_loop() {
1346        // G11 regression: a 13-byte buffer whose header declares
1347        // count = u32::MAX must be rejected up-front — the
1348        // deserializer must NOT spin through u32::MAX iterations
1349        // (or allocate Vec::with_capacity(count)).
1350        let mut bytes = Vec::new();
1351        bytes.extend_from_slice(&MAGIC);
1352        bytes.push(FORMAT_VERSION);
1353        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1354        // No entries follow — buffer is just the 9-byte header.
1355        let err = deserialize(&bytes).unwrap_err();
1356        assert!(matches!(err, IndexError::Corrupt));
1357    }
1358
1359    #[test]
1360    fn validate_path_basic() {
1361        assert!(validate_index_path("a.txt"));
1362        assert!(validate_index_path("src/main.rs"));
1363        assert!(validate_index_path(".mkitignore"));
1364        assert!(!validate_index_path(""));
1365        assert!(!validate_index_path("/abs"));
1366        assert!(!validate_index_path("../escape"));
1367        assert!(!validate_index_path("a/../b"));
1368        assert!(!validate_index_path(".mkit"));
1369        assert!(!validate_index_path(".git"));
1370        assert!(!validate_index_path(".mkit/objects"));
1371        assert!(!validate_index_path(".git/HEAD"));
1372        assert!(!validate_index_path("a\\b"));
1373        assert!(!validate_index_path("a//b"));
1374    }
1375
1376    #[test]
1377    fn from_tree_flattens_tree_entries() {
1378        use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1379        use crate::serialize;
1380        use crate::store::ObjectStore;
1381
1382        fn put(store: &ObjectStore, obj: &Object) -> Hash {
1383            let bytes = serialize::serialize(obj).unwrap();
1384            store.write(&bytes).unwrap()
1385        }
1386
1387        let dir = TempDir::new().unwrap();
1388        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1389        let file = put(
1390            &store,
1391            &Object::Blob(Blob {
1392                data: b"file".to_vec(),
1393            }),
1394        );
1395        let exec = put(
1396            &store,
1397            &Object::Blob(Blob {
1398                data: b"exec".to_vec(),
1399            }),
1400        );
1401        let link = put(
1402            &store,
1403            &Object::Blob(Blob {
1404                data: b"target".to_vec(),
1405            }),
1406        );
1407        let sub = put(
1408            &store,
1409            &Object::Tree(Tree {
1410                entries: vec![TreeEntry {
1411                    name: b"run".to_vec(),
1412                    mode: EntryMode::Executable,
1413                    object_hash: exec,
1414                }],
1415            }),
1416        );
1417        let root = put(
1418            &store,
1419            &Object::Tree(Tree {
1420                entries: vec![
1421                    TreeEntry {
1422                        name: b"file.txt".to_vec(),
1423                        mode: EntryMode::Blob,
1424                        object_hash: file,
1425                    },
1426                    TreeEntry {
1427                        name: b"link".to_vec(),
1428                        mode: EntryMode::Symlink,
1429                        object_hash: link,
1430                    },
1431                    TreeEntry {
1432                        name: b"sub".to_vec(),
1433                        mode: EntryMode::Tree,
1434                        object_hash: sub,
1435                    },
1436                ],
1437            }),
1438        );
1439
1440        let idx = from_tree(&store, root).unwrap();
1441        assert_eq!(idx.entries.len(), 3);
1442        assert_eq!(idx.entries[0].path, "file.txt");
1443        assert_eq!(idx.entries[0].status, EntryStatus::Blob);
1444        assert_eq!(idx.entries[1].path, "link");
1445        assert_eq!(idx.entries[1].status, EntryStatus::Symlink);
1446        assert_eq!(idx.entries[2].path, "sub/run");
1447        assert_eq!(idx.entries[2].status, EntryStatus::Executable);
1448    }
1449
1450    #[test]
1451    fn from_tree_round_trips_through_worktree_builder() {
1452        use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1453        use crate::serialize;
1454        use crate::store::ObjectStore;
1455
1456        fn put(store: &ObjectStore, obj: &Object) -> Hash {
1457            let bytes = serialize::serialize(obj).unwrap();
1458            store.write(&bytes).unwrap()
1459        }
1460
1461        let dir = TempDir::new().unwrap();
1462        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1463        let blob = put(
1464            &store,
1465            &Object::Blob(Blob {
1466                data: b"content".to_vec(),
1467            }),
1468        );
1469        let tree = put(
1470            &store,
1471            &Object::Tree(Tree {
1472                entries: vec![TreeEntry {
1473                    name: b"a.txt".to_vec(),
1474                    mode: EntryMode::Blob,
1475                    object_hash: blob,
1476                }],
1477            }),
1478        );
1479
1480        let idx = from_tree(&store, tree).unwrap();
1481        let rebuilt = crate::worktree::build_tree_from_index(&store, &idx).unwrap();
1482        assert_eq!(rebuilt, tree);
1483    }
1484
1485    // ---- by_path (issue #708) --------------------------------------------
1486
1487    fn blob_entry(path: &str) -> IndexEntry {
1488        IndexEntry {
1489            path: path.to_string(),
1490            status: EntryStatus::Blob,
1491            object_hash: seed_hash(path),
1492            mtime_ns: 0,
1493            size: 0,
1494            ino: 0,
1495            ctime_ns: 0,
1496        }
1497    }
1498
1499    /// `upsert_entry` on a fresh path appends and registers it; `find_entry`
1500    /// then answers via the map, not a scan. Each call self-checks
1501    /// consistency in debug builds (see `debug_assert_consistent`), so this
1502    /// test alone exercises the map on every one of its mutations.
1503    #[test]
1504    fn upsert_entry_inserts_new_path() {
1505        let mut idx = Index::new();
1506        idx.upsert_entry(blob_entry("a.txt"));
1507        idx.upsert_entry(blob_entry("b.txt"));
1508        assert_eq!(idx.entries.len(), 2);
1509        assert_eq!(idx.find_entry("a.txt"), Some(0));
1510        assert_eq!(idx.find_entry("b.txt"), Some(1));
1511        assert_eq!(idx.find_entry("missing"), None);
1512    }
1513
1514    /// `upsert_entry` on an already-tracked path replaces in place — same
1515    /// position, no growth, and the map still resolves it.
1516    #[test]
1517    fn upsert_entry_replaces_existing_path() {
1518        let mut idx = Index::new();
1519        idx.upsert_entry(blob_entry("a.txt"));
1520        idx.upsert_entry(blob_entry("b.txt"));
1521        let mut replacement = blob_entry("a.txt");
1522        replacement.status = EntryStatus::Executable;
1523        idx.upsert_entry(replacement);
1524        assert_eq!(idx.entries.len(), 2, "replace must not grow the index");
1525        let pos = idx.find_entry("a.txt").unwrap();
1526        assert_eq!(idx.entries[pos].status, EntryStatus::Executable);
1527        // The unrelated entry's position is untouched.
1528        assert_eq!(idx.find_entry("b.txt"), Some(1));
1529    }
1530
1531    /// `remove_path` drops the entry and shifts later positions in the map
1532    /// to match the `Vec::remove` shift.
1533    #[test]
1534    fn remove_path_updates_positions_of_later_entries() {
1535        let mut idx = Index::new();
1536        idx.upsert_entry(blob_entry("a.txt"));
1537        idx.upsert_entry(blob_entry("b.txt"));
1538        idx.upsert_entry(blob_entry("c.txt"));
1539        let removed = idx.remove_path("a.txt").expect("a.txt was tracked");
1540        assert_eq!(removed.path, "a.txt");
1541        assert_eq!(idx.entries.len(), 2);
1542        assert_eq!(idx.find_entry("a.txt"), None);
1543        assert_eq!(idx.entries[idx.find_entry("b.txt").unwrap()].path, "b.txt");
1544        assert_eq!(idx.entries[idx.find_entry("c.txt").unwrap()].path, "c.txt");
1545        // Removing an absent path is a documented no-op.
1546        assert!(idx.remove_path("a.txt").is_none());
1547    }
1548
1549    /// `retain_entries` rebuilds the map so positions stay correct for
1550    /// every survivor, regardless of how many entries were dropped.
1551    #[test]
1552    fn retain_entries_rebuilds_positions() {
1553        let mut idx = Index::new();
1554        for p in ["a.txt", "b.txt", "c.txt", "d.txt"] {
1555            idx.upsert_entry(blob_entry(p));
1556        }
1557        idx.retain_entries(|e| e.path != "b.txt" && e.path != "c.txt");
1558        assert_eq!(idx.entries.len(), 2);
1559        assert_eq!(idx.entries[idx.find_entry("a.txt").unwrap()].path, "a.txt");
1560        assert_eq!(idx.entries[idx.find_entry("d.txt").unwrap()].path, "d.txt");
1561        assert_eq!(idx.find_entry("b.txt"), None);
1562        assert_eq!(idx.find_entry("c.txt"), None);
1563    }
1564
1565    /// The common `add -A` case: staging an unrelated file has no ancestor
1566    /// or descendant conflict, so nothing is removed.
1567    #[test]
1568    fn remove_directory_conflicts_is_noop_without_conflict() {
1569        let mut idx = Index::new();
1570        idx.upsert_entry(blob_entry("src/lib.rs"));
1571        idx.remove_directory_conflicts("src/main.rs");
1572        assert_eq!(idx.entries.len(), 1);
1573        assert!(idx.find_entry("src/lib.rs").is_some());
1574    }
1575
1576    /// Staging `a/b` when `a` is already tracked as a file evicts the
1577    /// ancestor entry.
1578    #[test]
1579    fn remove_directory_conflicts_evicts_tracked_ancestor() {
1580        let mut idx = Index::new();
1581        idx.upsert_entry(blob_entry("a"));
1582        idx.remove_directory_conflicts("a/b");
1583        assert_eq!(idx.find_entry("a"), None);
1584        assert_eq!(idx.entries.len(), 0);
1585    }
1586
1587    /// Staging `a` when `a/b` is already tracked as a file evicts the
1588    /// descendant entry, leaving unrelated entries alone.
1589    #[test]
1590    fn remove_directory_conflicts_evicts_tracked_descendant() {
1591        let mut idx = Index::new();
1592        idx.upsert_entry(blob_entry("a/b"));
1593        idx.upsert_entry(blob_entry("a/c"));
1594        idx.upsert_entry(blob_entry("unrelated.txt"));
1595        idx.remove_directory_conflicts("a");
1596        assert_eq!(idx.find_entry("a/b"), None);
1597        assert_eq!(idx.find_entry("a/c"), None);
1598        assert!(idx.find_entry("unrelated.txt").is_some());
1599        assert_eq!(idx.entries.len(), 1);
1600    }
1601
1602    /// An entry at exactly the staged path is left alone — the caller
1603    /// upserts it separately.
1604    #[test]
1605    fn remove_directory_conflicts_keeps_exact_match() {
1606        let mut idx = Index::new();
1607        idx.upsert_entry(blob_entry("a.txt"));
1608        idx.remove_directory_conflicts("a.txt");
1609        assert!(idx.find_entry("a.txt").is_some());
1610    }
1611
1612    /// `deserialize` (and therefore `read_index`) populates `by_path`
1613    /// without any caller having to call `upsert_entry` — `find_entry` on
1614    /// the result is immediately map-backed.
1615    #[test]
1616    fn deserialize_populates_path_index() {
1617        let mut built = Index::new();
1618        built.upsert_entry(blob_entry("a.txt"));
1619        built.upsert_entry(blob_entry("dir/b.txt"));
1620        let round_tripped = deserialize(&built.serialize()).unwrap();
1621        assert_eq!(round_tripped.find_entry("a.txt"), Some(0));
1622        assert_eq!(round_tripped.find_entry("dir/b.txt"), Some(1));
1623        assert!(round_tripped.tracks_path_or_descendant("dir"));
1624    }
1625
1626    /// Two indexes with identical entries compare equal regardless of
1627    /// whether their `by_path` cache happens to be populated — equality is
1628    /// about staged content, not internal cache state.
1629    #[test]
1630    fn equality_ignores_path_index_population() {
1631        let mut via_field_push = Index::new();
1632        via_field_push.entries.push(blob_entry("a.txt"));
1633        let mut via_upsert = Index::new();
1634        via_upsert.upsert_entry(blob_entry("a.txt"));
1635        assert_eq!(via_field_push, via_upsert);
1636    }
1637}