git_policy_cli/index/index_flags.rs
1//! What:
2//! A read of the per-entry flags of an index file that decide whether the index may seed an explicit-path commit:
3//! whether any entry is marked assume-unchanged, skip-worktree or intent-to-add.
4//! Why:
5//! A private index copied from the real index and reset to the base keeps those marks, and `git add --all -- <path>`
6//! then skips a marked file the commit named (assume-unchanged) or refuses it (skip-worktree), where Git's own partial
7//! commit and the previous flow staged it; an intent-to-add entry of a tracked empty file survives the reset and is then
8//! left out of the tree, so the commit loses that file. Finding a mark needs the whole file, and a Git start for it would add a
9//! process to every commit, so the entries are read here; anything this reader does not fully understand answers
10//! "unreadable", and the caller then builds the index the slower, always-correct way.
11//! The layout is the one in Git's `gitformat-index` documentation: a 12-byte header, entries (versions 2 to 4), then
12//! extensions and a trailing checksum.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const flags = readIndexFlags(await readFile(indexPath)); // 'plain' | 'marked' | 'unreadable'
17//! ```
18
19/// Bit of the 16-bit entry flags that marks assume-unchanged.
20const ASSUME_VALID: u16 = 0x8000;
21
22/// Bit of the 16-bit entry flags that says a second 16-bit flags field follows.
23const EXTENDED: u16 = 0x4000;
24
25/// Bit of the extended flags that marks skip-worktree.
26const SKIP_WORKTREE: u16 = 0x4000;
27
28/// Bit of the extended flags that marks intent-to-add.
29/// Such an entry keeps the empty blob's name and is left out of any tree built from the index, so a copy that a one-tree
30/// `read-tree --reset` leaves in place drops a tracked empty file from the commit.
31const INTENT_TO_ADD: u16 = 0x2000;
32
33/// Bytes of the index header: signature, version and entry count.
34const HEADER_LENGTH: usize = 12;
35
36/// Bytes of an entry before its object name: ten 32-bit stat fields.
37const STAT_LENGTH: usize = 40;
38
39/// Object name lengths tried in turn: SHA-1 and SHA-256.
40const OBJECT_NAME_LENGTHS: [usize; 2] = [20, 32];
41
42/// The extension that makes the entries live partly in another file, which this reader does not follow.
43const SPLIT_INDEX_EXTENSION: &[u8; 4] = b"link";
44
45/// What a read of an index file found.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum IndexFlags {
48 /// Every entry was read and none is marked assume-unchanged, skip-worktree or intent-to-add.
49 Plain,
50 /// At least one entry is marked assume-unchanged, skip-worktree or intent-to-add.
51 Marked,
52 /// The file is not an index this reader fully understands (an unknown version, a split index, a layout that does
53 /// not add up), so nothing is known about its marks.
54 Unreadable,
55}
56
57/// Big-endian 16-bit value at `at`.
58fn be16(bytes: &[u8], at: usize) -> Option<u16> {
59 let pair: &[u8] = bytes.get(at..at.checked_add(2)?)?;
60 return Some(u16::from_be_bytes([pair[0], pair[1]]));
61}
62
63/// Big-endian 32-bit value at `at`.
64fn be32(bytes: &[u8], at: usize) -> Option<u32> {
65 let quad: &[u8] = bytes.get(at..at.checked_add(4)?)?;
66 return Some(u32::from_be_bytes([quad[0], quad[1], quad[2], quad[3]]));
67}
68
69/// What:
70/// Reads every entry and extension of an index whose object names are `name_length` bytes long.
71/// Why:
72/// A wrong name length misplaces the entries, so the layout stops adding up and the read answers nothing;
73/// a right one consumes the file exactly up to its checksum.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// scanEntries(bytes, nameLength) // true when any entry is marked; undefined when the layout is not understood
78/// ```
79fn scan_entries(bytes: &[u8], name_length: usize) -> Option<bool> {
80 let end: usize = bytes.len().checked_sub(name_length)?;
81 if bytes.get(..4)? != b"DIRC" {
82 return None;
83 }
84 let version: u32 = be32(bytes, 4)?;
85 if !(2..=4).contains(&version) {
86 return None;
87 }
88 let count: u32 = be32(bytes, 8)?;
89 let mut marked: bool = false;
90 let mut at: usize = HEADER_LENGTH;
91 for _entry in 0..count {
92 let flags: u16 = be16(bytes, at.checked_add(STAT_LENGTH)?.checked_add(name_length)?)?;
93 let mut head: usize = STAT_LENGTH + name_length + 2;
94 if flags & ASSUME_VALID != 0 {
95 marked = true;
96 }
97 if flags & EXTENDED != 0 {
98 if version < 3 {
99 return None;
100 }
101 if be16(bytes, at.checked_add(head)?)? & (SKIP_WORKTREE | INTENT_TO_ADD) != 0 {
102 marked = true;
103 }
104 head += 2;
105 }
106 let name_start: usize = at.checked_add(head)?;
107 if version == 4 {
108 // The strip count is a varint; its last byte has no continuation bit.
109 let mut cursor: usize = name_start;
110 while bytes.get(cursor)? & 0x80 != 0 {
111 cursor += 1;
112 }
113 cursor += 1;
114 let terminator: usize = bytes.get(cursor..end)?.iter().position(|byte| { return *byte == 0; })?;
115 at = cursor + terminator + 1;
116 } else {
117 let terminator: usize = bytes.get(name_start..end)?.iter().position(|byte| { return *byte == 0; })?;
118 // An entry is padded with one to eight NULs to a multiple of eight bytes from its own start.
119 at = at.checked_add((head + terminator + 8) & !7)?;
120 }
121 if at > end {
122 return None;
123 }
124 }
125 while at < end {
126 let signature: &[u8] = bytes.get(at..at.checked_add(4)?)?;
127 if !signature.iter().all(u8::is_ascii_alphanumeric) || signature == SPLIT_INDEX_EXTENSION {
128 return None;
129 }
130 let size: usize = usize::try_from(be32(bytes, at + 4)?).ok()?;
131 at = at.checked_add(8)?.checked_add(size)?;
132 }
133 if at != end {
134 return None;
135 }
136 return Some(marked);
137}
138
139/// What:
140/// Whether an index file has an entry marked assume-unchanged or skip-worktree.
141/// Why:
142/// Both object name lengths are tried because the reader is not told the repository's object format;
143/// a layout that adds up for neither is unreadable, never "plain".
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// readIndexFlags(bytes)
148/// ```
149pub fn read_index_flags(bytes: &[u8]) -> IndexFlags {
150 for name_length in OBJECT_NAME_LENGTHS {
151 match scan_entries(bytes, name_length) {
152 Some(true) => return IndexFlags::Marked,
153 Some(false) => return IndexFlags::Plain,
154 None => {}
155 }
156 }
157 return IndexFlags::Unreadable;
158}
159
160/// Reader controls stay out of the release executable.
161#[cfg(test)]
162#[path = "index_flags_tests.rs"]
163mod tests;