1// @effect-diagnostics nodeBuiltinImport:off - Pure helpers; the index checksum is SHA-1 over bytes.
2import * as NodeCrypto from "node:crypto";
3
4/** What Git compares in an index entry's stat data. Numbers are already truncated to 32 bits. */
5export interface IndexEntryStat {
6 readonly ctimeSeconds: number;
7 readonly ctimeNanoseconds: number;
8 readonly mtimeSeconds: number;
9 readonly mtimeNanoseconds: number;
10 readonly dev: number;
11 readonly ino: number;
12 readonly uid: number;
13 readonly gid: number;
14 readonly size: number;
15 readonly kind: "file" | "executable" | "symlink" | "directory" | "other";
16}
17
18export interface ParsedIndex {
19 readonly bytes: Uint8Array;
20 readonly entries: ReadonlyArray<{ readonly offset: number; readonly path: string }>;
21}
22
23const ENTRY_FIXED_BYTES = 62;
24const SHA1_BYTES = 20;
25const MODE_TYPE_MASK = 0o170000;
26const MODE_REGULAR = 0o100000;
27const MODE_SYMLINK = 0o120000;
28const MODE_GITLINK = 0o160000;
29const FLAG_ASSUME_VALID = 0x8000;
30const FLAG_EXTENDED = 0x4000;
31const FLAG_STAGE = 0x3000;
32const FLAG_NAME_LENGTH = 0x0fff;
33const EXTENDED_SKIP_WORKTREE = 0x4000;
34// Extensions that let Git trust an entry without checking the file, or that point at another index.
35const UNSAFE_EXTENSIONS = new Set(["link", "FSMN", "sdir", "UNTR"]);
36
37const decoder = new TextDecoder();
38
39/**
40 * Reads a SHA-1 index that T3 wrote itself (versions 2 and 3). Anything else, including
41 * conflicts, sparse entries, split indexes and fsmonitor data, returns null so callers use Git.
42 */
43export function parseIndex(bytes: Uint8Array): ParsedIndex | null {
44 if (bytes.length < 12 + SHA1_BYTES) return null;
45 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
46 if (decoder.decode(bytes.subarray(0, 4)) !== "DIRC") return null;
47 const version = view.getUint32(4);
48 if (version !== 2 && version !== 3) return null;
49 const expected = NodeCrypto.createHash("sha1")
50 .update(bytes.subarray(0, bytes.length - SHA1_BYTES))
51 .digest();
52 if (!expected.equals(bytes.subarray(bytes.length - SHA1_BYTES))) return null;
53
54 const count = view.getUint32(8);
55 const entries: Array<{ offset: number; path: string }> = [];
56 let offset = 12;
57 for (let index = 0; index < count; index++) {
58 if (offset + ENTRY_FIXED_BYTES > bytes.length - SHA1_BYTES) return null;
59 const flags = view.getUint16(offset + 60);
60 if (flags & (FLAG_ASSUME_VALID | FLAG_STAGE)) return null;
61 let nameStart = offset + ENTRY_FIXED_BYTES;
62 if (flags & FLAG_EXTENDED) {
63 if (version < 3 || view.getUint16(nameStart) & EXTENDED_SKIP_WORKTREE) return null;
64 nameStart += 2;
65 }
66 const nameLength = flags & FLAG_NAME_LENGTH;
67 const nameEnd =
68 nameLength < FLAG_NAME_LENGTH ? nameStart + nameLength : bytes.indexOf(0, nameStart);
69 if (nameEnd < 0 || bytes[nameEnd] !== 0) return null;
70 entries.push({ offset, path: decoder.decode(bytes.subarray(nameStart, nameEnd)) });
71 // Entries are NUL-padded to a multiple of eight bytes, with at least one NUL.
72 offset += (nameEnd - offset + 8) & ~7;
73 }
74
75 // Extensions follow the entries as a 4-byte signature and a 4-byte length.
76 while (offset < bytes.length - SHA1_BYTES) {
77 if (offset + 8 > bytes.length - SHA1_BYTES) return null;
78 if (UNSAFE_EXTENSIONS.has(decoder.decode(bytes.subarray(offset, offset + 4)))) return null;
79 offset += 8 + view.getUint32(offset + 4);
80 }
81 return offset === bytes.length - SHA1_BYTES ? { bytes, entries } : null;
82}
83
84function kindForMode(mode: number): IndexEntryStat["kind"] {
85 switch (mode & MODE_TYPE_MASK) {
86 case MODE_REGULAR:
87 return mode & 0o111 ? "executable" : "file";
88 case MODE_SYMLINK:
89 return "symlink";
90 case MODE_GITLINK:
91 return "directory";
92 default:
93 return "other";
94 }
95}
96
97/**
98 * Returns a copy of the index with each entry's stat data replaced by `stats[i]`, so Git trusts
99 * files it did not write itself. With `verify`, an entry whose recorded mtime, size or type
100 * differs from its file makes the result null: the files no longer match what Git checked out.
101 * A missing stat (an uninitialized submodule folder) keeps the entry's recorded data.
102 */
103export function restatIndex(
104 parsed: ParsedIndex,
105 stats: ReadonlyArray<IndexEntryStat | undefined>,
106 options: { readonly verify: boolean },
107): Uint8Array | null {
108 const bytes = new Uint8Array(parsed.bytes);
109 const view = new DataView(bytes.buffer);
110 // Two tracked paths on one file, as on a case-insensitive volume, cannot both match.
111 const files = new Set<string>();
112 for (const [index, entry] of parsed.entries.entries()) {
113 const stat = stats[index];
114 const offset = entry.offset;
115 const kind = kindForMode(view.getUint32(offset + 24));
116 if (stat === undefined) {
117 if (kind !== "directory") return null;
118 continue;
119 }
120 if (stat.kind !== kind) return null;
121 if (kind !== "directory") {
122 const file = `${stat.dev}:${stat.ino}`;
123 if (files.has(file)) return null;
124 files.add(file);
125 }
126 if (
127 options.verify &&
128 kind !== "directory" &&
129 (view.getUint32(offset + 8) !== stat.mtimeSeconds ||
130 view.getUint32(offset + 12) !== stat.mtimeNanoseconds ||
131 view.getUint32(offset + 36) !== stat.size)
132 ) {
133 return null;
134 }
135 view.setUint32(offset, stat.ctimeSeconds);
136 view.setUint32(offset + 4, stat.ctimeNanoseconds);
137 view.setUint32(offset + 8, stat.mtimeSeconds);
138 view.setUint32(offset + 12, stat.mtimeNanoseconds);
139 view.setUint32(offset + 16, stat.dev);
140 view.setUint32(offset + 20, stat.ino);
141 view.setUint32(offset + 28, stat.uid);
142 view.setUint32(offset + 32, stat.gid);
143 view.setUint32(offset + 36, stat.size);
144 }
145 bytes.set(
146 NodeCrypto.createHash("sha1")
147 .update(bytes.subarray(0, bytes.length - SHA1_BYTES))
148 .digest(),
149 bytes.length - SHA1_BYTES,
150 );
151 return bytes;
152}
153
154const U32 = 0xffff_ffffn;
155const BILLION = 1_000_000_000n;
156
157/** Converts a bigint `lstat` result the way Git does: each field truncated to 32 bits. */
158export function indexEntryStat(stat: {
159 readonly ctimeNs: bigint;
160 readonly mtimeNs: bigint;
161 readonly dev: bigint;
162 readonly ino: bigint;
163 readonly uid: bigint;
164 readonly gid: bigint;
165 readonly size: bigint;
166 readonly mode: bigint;
167}): IndexEntryStat {
168 const type = Number(stat.mode) & MODE_TYPE_MASK;
169 return {
170 ctimeSeconds: Number((stat.ctimeNs / BILLION) & U32),
171 ctimeNanoseconds: Number(stat.ctimeNs % BILLION),
172 mtimeSeconds: Number((stat.mtimeNs / BILLION) & U32),
173 mtimeNanoseconds: Number(stat.mtimeNs % BILLION),
174 dev: Number(stat.dev & U32),
175 ino: Number(stat.ino & U32),
176 uid: Number(stat.uid & U32),
177 gid: Number(stat.gid & U32),
178 size: Number(stat.size & U32),
179 kind:
180 type === MODE_REGULAR
181 ? Number(stat.mode) & 0o111
182 ? "executable"
183 : "file"
184 : type === MODE_SYMLINK
185 ? "symlink"
186 : type === 0o040000
187 ? "directory"
188 : "other",
189 };
190}