1use 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
37pub const MAGIC: [u8; 4] = *b"MKIX";
39pub const FORMAT_VERSION: u8 = 0x02;
41pub const MAX_INDEX_BYTES: u64 = 64 * 1024 * 1024;
43pub const MAX_PATH_LEN: usize = 4096;
45
46pub const INDEX_FILE: &str = ".mkit/index";
48
49#[repr(u8)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum EntryStatus {
53 Removed = 0x00,
55 Blob = 0x01,
57 Tree = 0x02,
59 Symlink = 0x03,
61 Executable = 0x04,
63}
64
65impl EntryStatus {
66 #[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#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct IndexEntry {
83 pub path: String,
85 pub status: EntryStatus,
87 pub object_hash: Hash,
89 pub mtime_ns: u64,
93 pub size: u64,
96 pub ino: u64,
100 pub ctime_ns: u64,
104}
105
106#[derive(Debug, Default, Clone)]
119pub struct Index {
120 pub entries: Vec<IndexEntry>,
122 by_path: BTreeMap<String, usize>,
132}
133
134impl 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 #[must_use]
150 pub const fn new() -> Self {
151 Self {
152 entries: Vec::new(),
153 by_path: BTreeMap::new(),
154 }
155 }
156
157 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 #[must_use]
180 pub fn find_entry(&self, path: &str) -> Option<usize> {
181 self.by_path.get(path).copied()
182 }
183
184 #[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 #[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 #[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 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 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 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 pub fn retain_entries(&mut self, keep: impl FnMut(&IndexEntry) -> bool) {
269 self.entries.retain(keep);
270 self.rebuild_path_index();
271 }
272
273 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 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 #[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 #[must_use]
346 pub fn serialize(&self) -> Vec<u8> {
347 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
375fn ancestor_prefixes(path: &str) -> impl Iterator<Item = &str> {
380 path.match_indices('/').map(move |(i, _)| &path[..i])
381}
382
383fn 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#[derive(Debug, thiserror::Error)]
396pub enum IndexError {
397 #[error("index file has wrong magic (expected MKIX)")]
399 BadMagic,
400 #[error("unsupported index version: {0:#x}")]
402 UnsupportedVersion(u8),
403 #[error("index entry has unknown status byte {0:#x}")]
405 BadStatus(u8),
406 #[error("index file is corrupt")]
408 Corrupt,
409 #[error("index file too large (>{MAX_INDEX_BYTES} bytes)")]
411 TooLarge,
412 #[error("invalid index path '{0}'")]
414 InvalidPath(String),
415 #[error("duplicate index path '{0}'")]
417 DuplicatePath(String),
418 #[error("removed index entry '{0}' has nonzero object_hash")]
421 RemovedHasHash(String),
422 #[error("index path is not valid UTF-8")]
424 InvalidPathEncoding,
425 #[error(transparent)]
427 Io(#[from] io::Error),
428 #[error(transparent)]
430 Store(#[from] StoreError),
431 #[error("object is not a tree")]
433 NotTree,
434 #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
437 TreeTooDeep,
438}
439
440pub type IndexResult<T> = Result<T, IndexError>;
442
443pub 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 let stat_cache_len: usize = 32;
465 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 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)); 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
536pub 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 let index_mtime_ns = crate::worktree::mtime_nanos(&meta);
564 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
591const RACY_WINDOW_NS: u64 = 1_000_000_000;
598
599pub 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
614pub 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 mtime_ns: 0,
671 size: 0,
672 ino: 0,
673 ctime_ns: 0,
674 });
675 }
676 }
677 }
678 Ok(())
679}
680
681#[must_use]
683pub fn index_path(layout: &RepoLayout) -> PathBuf {
684 layout.index_file()
685}
686
687#[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 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 #[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); expected.extend_from_slice(&1u32.to_le_bytes());
768 expected.push(0x01); 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 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 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 #[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 #[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, size: 7,
857 ino: 0,
858 ctime_ns: 0,
859 },
860 ]);
861 write_index(&layout, &idx).unwrap();
862 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 #[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; 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 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 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 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 assert!(idx.tracks_path_or_descendant("src/lib.rs"));
969 assert!(idx.tracks_path_or_descendant("src"));
970 assert!(!idx.tracks_path_or_descendant("sr"));
972 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 assert!(idx.has_tracked_file_at("f"));
1000 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 assert!(!idx.has_tracked_file_at("gone"));
1016 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 let bytes = [
1087 0x5A,
1088 0x4D,
1089 0x49,
1090 0x58, 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); 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]); 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 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); bytes.extend_from_slice(&[0u8; HASH_LEN]);
1216 bytes.extend_from_slice(&[0u8; 32]); 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 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")); bytes.extend_from_slice(&[0u8; 32]); 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 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 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 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 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 #[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 #[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 assert_eq!(idx.find_entry("b.txt"), Some(1));
1529 }
1530
1531 #[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 assert!(idx.remove_path("a.txt").is_none());
1547 }
1548
1549 #[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 #[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 #[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 #[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 #[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 #[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 #[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}