gent-cli 14.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ /**
2
+ * ============================================================================
3
+ * Git Objects - canonical serialization and parsing
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Byte-exact encoding and decoding of the four Git object types under
8
+ * SHA-256. This module owns object identity: nothing else in Gent may
9
+ * compute an object id.
10
+ *
11
+ * FRAMING:
12
+ * "<type> <payload byte length>\0" + payload, hashed with SHA-256.
13
+ *
14
+ * BYTE PRESERVATION:
15
+ * Commits and tags carry unknown headers, multi-line signatures and exact
16
+ * message bytes. Parsing is for display only — serialize(parse(x)) === x for
17
+ * every object this module accepts. Callers re-emitting a stored object must
18
+ * still prefer its raw payload; the round trip is a safety net, not a
19
+ * licence to rewrite history.
20
+ *
21
+ * See docs/git-compat/format-contract.md sections 1, 3, 4, 5.
22
+ * ============================================================================
23
+ */
24
+
25
+ const crypto = require('crypto');
26
+
27
+ const OID_RAW_LENGTH = 32;
28
+ const OID_HEX_LENGTH = 64;
29
+ const OBJECT_TYPES = Object.freeze(['blob', 'tree', 'commit', 'tag']);
30
+ const NULL_OID = '0'.repeat(OID_HEX_LENGTH);
31
+
32
+ /** Modes Gent is allowed to write. Others are read-only pass-through. */
33
+ const MODE = Object.freeze({
34
+ TREE: 0o40000,
35
+ REGULAR: 0o100644,
36
+ EXECUTABLE: 0o100755,
37
+ SYMLINK: 0o120000,
38
+ GITLINK: 0o160000
39
+ });
40
+ const WRITABLE_MODES = new Set(Object.values(MODE));
41
+
42
+ const MAX_TAG_PEEL_DEPTH = 32;
43
+
44
+ /** Raised for any object that does not satisfy the format contract. */
45
+ class MalformedObjectError extends Error {
46
+ constructor(message, details) {
47
+ super(message);
48
+ this.name = 'MalformedObjectError';
49
+ this.code = 'GENT_MALFORMED_OBJECT';
50
+ Object.assign(this, details || {});
51
+ }
52
+ }
53
+
54
+ // ─── Object ids ──────────────────────────────────────────
55
+
56
+ /**
57
+ * @param {String} value
58
+ * @returns {Boolean}
59
+ */
60
+ function isObjectId(value) {
61
+ return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
62
+ }
63
+
64
+ /**
65
+ * @param {String} value
66
+ * @param {String} [what]
67
+ * @returns {String} the same value
68
+ */
69
+ function assertObjectId(value, what) {
70
+ if (!isObjectId(value)) {
71
+ throw new MalformedObjectError(`${what || 'object id'} is not a 64-character lowercase hex SHA-256: ${JSON.stringify(value)}`);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ /**
77
+ * @param {String} hex
78
+ * @returns {Buffer} 32 raw bytes
79
+ */
80
+ function oidToRaw(hex) {
81
+ return Buffer.from(assertObjectId(hex), 'hex');
82
+ }
83
+
84
+ /**
85
+ * @param {Buffer} buf
86
+ * @param {Number} [offset]
87
+ * @returns {String} 64 hex characters
88
+ */
89
+ function rawToOid(buf, offset = 0) {
90
+ if (buf.length < offset + OID_RAW_LENGTH) {
91
+ throw new MalformedObjectError('truncated object id');
92
+ }
93
+ return buf.toString('hex', offset, offset + OID_RAW_LENGTH);
94
+ }
95
+
96
+ // ─── Framing ─────────────────────────────────────────────
97
+
98
+ /**
99
+ * Build the exact bytes that are hashed and stored.
100
+ * @param {String} type
101
+ * @param {Buffer} payload
102
+ * @returns {Buffer}
103
+ */
104
+ function frameObject(type, payload) {
105
+ if (!OBJECT_TYPES.includes(type)) {
106
+ throw new MalformedObjectError(`unknown object type '${type}'`);
107
+ }
108
+ if (!Buffer.isBuffer(payload)) {
109
+ throw new TypeError('object payload must be a Buffer — no implicit text encoding on the identity path');
110
+ }
111
+ return Buffer.concat([Buffer.from(`${type} ${payload.length}\0`, 'latin1'), payload]);
112
+ }
113
+
114
+ /**
115
+ * @param {String} type
116
+ * @param {Buffer} payload
117
+ * @returns {String} object id
118
+ */
119
+ function hashObject(type, payload) {
120
+ return crypto.createHash('sha256').update(frameObject(type, payload)).digest('hex');
121
+ }
122
+
123
+ /**
124
+ * Split stored bytes back into type and payload, validating the declared size.
125
+ * @param {Buffer} framed
126
+ * @returns {{type: String, size: Number, payload: Buffer}}
127
+ */
128
+ function unframeObject(framed) {
129
+ const nul = framed.indexOf(0);
130
+ if (nul < 0) throw new MalformedObjectError('object header has no NUL terminator');
131
+
132
+ const header = framed.toString('latin1', 0, nul);
133
+ const space = header.indexOf(' ');
134
+ if (space < 0) throw new MalformedObjectError(`object header has no size: ${JSON.stringify(header)}`);
135
+
136
+ const type = header.slice(0, space);
137
+ const sizeText = header.slice(space + 1);
138
+ if (!OBJECT_TYPES.includes(type)) {
139
+ throw new MalformedObjectError(`unknown object type '${type}'`);
140
+ }
141
+ if (!/^(0|[1-9][0-9]*)$/.test(sizeText)) {
142
+ throw new MalformedObjectError(`object size is not a canonical decimal: ${JSON.stringify(sizeText)}`);
143
+ }
144
+
145
+ const size = Number(sizeText);
146
+ const payload = framed.subarray(nul + 1);
147
+ if (payload.length !== size) {
148
+ throw new MalformedObjectError(`object size mismatch: header says ${size}, payload is ${payload.length}`);
149
+ }
150
+ return { type, size, payload };
151
+ }
152
+
153
+ // ─── Trees ───────────────────────────────────────────────
154
+
155
+ /**
156
+ * Git's base_name_compare: bytewise, with a directory's virtual next byte '/'.
157
+ * @param {{name: String, mode: Number}} a
158
+ * @param {{name: String, mode: Number}} b
159
+ * @returns {Number}
160
+ */
161
+ function compareTreeEntries(a, b) {
162
+ const an = Buffer.from(a.name, 'utf8');
163
+ const bn = Buffer.from(b.name, 'utf8');
164
+ const common = Math.min(an.length, bn.length);
165
+ const cmp = an.compare(bn, 0, common, 0, common);
166
+ if (cmp !== 0) return cmp;
167
+
168
+ let ac = an.length > common ? an[common] : (a.mode === MODE.TREE ? 0x2f : 0);
169
+ let bc = bn.length > common ? bn[common] : (b.mode === MODE.TREE ? 0x2f : 0);
170
+ return ac < bc ? -1 : ac > bc ? 1 : 0;
171
+ }
172
+
173
+ /**
174
+ * Reject names that cannot appear inside a tree object.
175
+ * @param {String} name
176
+ */
177
+ function assertTreeEntryName(name) {
178
+ if (typeof name !== 'string' || name.length === 0) {
179
+ throw new MalformedObjectError('tree entry name is empty');
180
+ }
181
+ if (name.includes('/')) {
182
+ throw new MalformedObjectError(`tree entry name must be a basename, got '${name}'`);
183
+ }
184
+ if (name.includes('\0')) {
185
+ throw new MalformedObjectError('tree entry name contains NUL');
186
+ }
187
+ if (name === '.' || name === '..') {
188
+ throw new MalformedObjectError(`tree entry name '${name}' is reserved`);
189
+ }
190
+ if (name.toLowerCase() === '.git') {
191
+ throw new MalformedObjectError("tree entry name '.git' is reserved");
192
+ }
193
+ }
194
+
195
+ /**
196
+ * @param {Array<{mode: Number, name: String, oid: String}>} entries
197
+ * @returns {Buffer}
198
+ */
199
+ function serializeTree(entries) {
200
+ const sorted = [...entries].sort(compareTreeEntries);
201
+
202
+ const parts = [];
203
+ let previous = null;
204
+ for (const entry of sorted) {
205
+ assertTreeEntryName(entry.name);
206
+ if (!WRITABLE_MODES.has(entry.mode)) {
207
+ throw new MalformedObjectError(`tree entry '${entry.name}' has mode ${entry.mode.toString(8)}, which Gent does not write`);
208
+ }
209
+ if (previous && previous.name === entry.name) {
210
+ throw new MalformedObjectError(`duplicate tree entry '${entry.name}'`);
211
+ }
212
+ previous = entry;
213
+ parts.push(Buffer.from(`${entry.mode.toString(8)} ${entry.name}\0`, 'utf8'));
214
+ parts.push(oidToRaw(entry.oid));
215
+ }
216
+ return Buffer.concat(parts);
217
+ }
218
+
219
+ /**
220
+ * @param {Buffer} payload
221
+ * @returns {Array<{mode: Number, name: String, oid: String, type: String}>}
222
+ */
223
+ function parseTree(payload) {
224
+ const entries = [];
225
+ let offset = 0;
226
+
227
+ while (offset < payload.length) {
228
+ const space = payload.indexOf(0x20, offset);
229
+ if (space < 0) throw new MalformedObjectError('tree entry has no mode separator');
230
+
231
+ const modeText = payload.toString('latin1', offset, space);
232
+ if (!/^[0-7]{5,6}$/.test(modeText)) {
233
+ throw new MalformedObjectError(`tree entry mode is not octal: ${JSON.stringify(modeText)}`);
234
+ }
235
+ if (modeText[0] === '0') {
236
+ throw new MalformedObjectError(`tree entry mode has a leading zero: ${modeText}`);
237
+ }
238
+
239
+ const nul = payload.indexOf(0, space + 1);
240
+ if (nul < 0) throw new MalformedObjectError('tree entry name has no NUL terminator');
241
+
242
+ const name = payload.toString('utf8', space + 1, nul);
243
+ const oid = rawToOid(payload, nul + 1);
244
+ const mode = parseInt(modeText, 8);
245
+
246
+ entries.push({ mode, name, oid, type: modeToType(mode) });
247
+ offset = nul + 1 + OID_RAW_LENGTH;
248
+ }
249
+
250
+ if (offset !== payload.length) throw new MalformedObjectError('trailing bytes in tree object');
251
+ return entries;
252
+ }
253
+
254
+ /**
255
+ * @param {Number} mode
256
+ * @returns {String} 'tree' | 'blob' | 'commit' (gitlink)
257
+ */
258
+ function modeToType(mode) {
259
+ if (mode === MODE.TREE) return 'tree';
260
+ if (mode === MODE.GITLINK) return 'commit';
261
+ return 'blob';
262
+ }
263
+
264
+ // ─── Identities ──────────────────────────────────────────
265
+
266
+ /**
267
+ * @param {{name: String, email: String, timestamp: Number, timezone: String}} identity
268
+ * @returns {String}
269
+ */
270
+ function formatIdentity(identity) {
271
+ const { name = '', email = '', timestamp, timezone } = identity;
272
+ if (!Number.isInteger(timestamp)) {
273
+ throw new MalformedObjectError(`identity timestamp must be integer epoch seconds, got ${timestamp}`);
274
+ }
275
+ if (!/^[+-][0-9]{4}$/.test(timezone)) {
276
+ throw new MalformedObjectError(`identity timezone must be ±HHMM, got ${JSON.stringify(timezone)}`);
277
+ }
278
+ if (name.includes('<') || name.includes('>') || name.includes('\n')) {
279
+ throw new MalformedObjectError(`identity name may not contain '<', '>' or a newline: ${JSON.stringify(name)}`);
280
+ }
281
+ if (email.includes('<') || email.includes('>') || email.includes('\n')) {
282
+ throw new MalformedObjectError(`identity email may not contain '<', '>' or a newline: ${JSON.stringify(email)}`);
283
+ }
284
+ return `${name} <${email}> ${timestamp} ${timezone}`;
285
+ }
286
+
287
+ /**
288
+ * Tolerant parse — display only. Never used to re-derive an object id.
289
+ * @param {String} value
290
+ * @returns {{name, email, timestamp, timezone, raw}}
291
+ */
292
+ function parseIdentity(value) {
293
+ const open = value.indexOf('<');
294
+ const close = value.indexOf('>', open + 1);
295
+ if (open < 0 || close < 0) {
296
+ return { name: value.trim(), email: '', timestamp: 0, timezone: '+0000', raw: value };
297
+ }
298
+ const name = value.slice(0, open).trimEnd();
299
+ const email = value.slice(open + 1, close);
300
+ const rest = value.slice(close + 1).trim().split(/\s+/);
301
+ const timestamp = Number.parseInt(rest[0], 10);
302
+ const timezone = /^[+-][0-9]{4}$/.test(rest[1] || '') ? rest[1] : '+0000';
303
+ return {
304
+ name,
305
+ email,
306
+ timestamp: Number.isFinite(timestamp) ? timestamp : 0,
307
+ timezone,
308
+ raw: value
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Local timezone offset in ±HHMM form for a Date.
314
+ * @param {Date} date
315
+ * @returns {String}
316
+ */
317
+ function timezoneOffset(date) {
318
+ const minutes = -date.getTimezoneOffset();
319
+ const sign = minutes < 0 ? '-' : '+';
320
+ const abs = Math.abs(minutes);
321
+ return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}${String(abs % 60).padStart(2, '0')}`;
322
+ }
323
+
324
+ // ─── Header block (shared by commit and tag) ─────────────
325
+
326
+ /**
327
+ * Split "header\n...\n\nmessage" preserving order, unknown headers and the
328
+ * one-leading-space continuation encoding used by multi-line signatures.
329
+ * @param {Buffer} payload
330
+ * @returns {{headers: Array<[String, String]>, message: Buffer}}
331
+ */
332
+ function parseHeaderBlock(payload) {
333
+ const headers = [];
334
+ let offset = 0;
335
+
336
+ while (offset < payload.length) {
337
+ if (payload[offset] === 0x0a) { // blank line ends headers
338
+ offset += 1;
339
+ break;
340
+ }
341
+ let end = payload.indexOf(0x0a, offset);
342
+ if (end < 0) end = payload.length;
343
+
344
+ const line = payload.toString('utf8', offset, end);
345
+ const space = line.indexOf(' ');
346
+ const key = space < 0 ? line : line.slice(0, space);
347
+ let value = space < 0 ? '' : line.slice(space + 1);
348
+ offset = end + 1;
349
+
350
+ // Continuation lines begin with a single space.
351
+ while (offset < payload.length && payload[offset] === 0x20) {
352
+ let contEnd = payload.indexOf(0x0a, offset);
353
+ if (contEnd < 0) contEnd = payload.length;
354
+ value += '\n' + payload.toString('utf8', offset + 1, contEnd);
355
+ offset = contEnd + 1;
356
+ }
357
+
358
+ headers.push([key, value]);
359
+ }
360
+
361
+ return { headers, message: payload.subarray(offset) };
362
+ }
363
+
364
+ /**
365
+ * @param {Array<[String, String]>} headers
366
+ * @param {Buffer} message
367
+ * @returns {Buffer}
368
+ */
369
+ function serializeHeaderBlock(headers, message) {
370
+ const parts = [];
371
+ for (const [key, value] of headers) {
372
+ if (key.includes(' ') || key.includes('\n')) {
373
+ throw new MalformedObjectError(`invalid object header key ${JSON.stringify(key)}`);
374
+ }
375
+ const encoded = String(value).split('\n').join('\n ');
376
+ parts.push(Buffer.from(`${key} ${encoded}\n`, 'utf8'));
377
+ }
378
+ parts.push(Buffer.from('\n', 'utf8'));
379
+ parts.push(Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8'));
380
+ return Buffer.concat(parts);
381
+ }
382
+
383
+ /**
384
+ * @param {Array<[String, String]>} headers
385
+ * @param {String} key
386
+ * @returns {String|null}
387
+ */
388
+ function headerValue(headers, key) {
389
+ const found = headers.find(([k]) => k === key);
390
+ return found ? found[1] : null;
391
+ }
392
+
393
+ // ─── Commits ─────────────────────────────────────────────
394
+
395
+ /**
396
+ * @param {Object} commit
397
+ * @param {String} commit.tree
398
+ * @param {Array<String>} [commit.parents]
399
+ * @param {Object} commit.author
400
+ * @param {Object} commit.committer
401
+ * @param {Buffer|String} commit.message
402
+ * @param {Array<[String, String]>} [commit.extraHeaders] - emitted after committer
403
+ * @returns {Buffer}
404
+ */
405
+ function serializeCommit(commit) {
406
+ const headers = [['tree', assertObjectId(commit.tree, 'commit tree')]];
407
+ for (const parent of commit.parents || []) {
408
+ headers.push(['parent', assertObjectId(parent, 'commit parent')]);
409
+ }
410
+ headers.push(['author', formatIdentity(commit.author)]);
411
+ headers.push(['committer', formatIdentity(commit.committer)]);
412
+ for (const [key, value] of commit.extraHeaders || []) {
413
+ headers.push([key, value]);
414
+ }
415
+ const message = Buffer.isBuffer(commit.message) ? commit.message : Buffer.from(commit.message || '', 'utf8');
416
+ return serializeHeaderBlock(headers, message);
417
+ }
418
+
419
+ /**
420
+ * @param {Buffer} payload
421
+ * @returns {Object} parsed commit, carrying `raw` and `headers` for fidelity
422
+ */
423
+ function parseCommit(payload) {
424
+ const { headers, message } = parseHeaderBlock(payload);
425
+
426
+ const tree = headerValue(headers, 'tree');
427
+ if (!tree) throw new MalformedObjectError('commit has no tree header');
428
+ assertObjectId(tree, 'commit tree');
429
+
430
+ const parents = headers.filter(([k]) => k === 'parent').map(([, v]) => assertObjectId(v, 'commit parent'));
431
+ const authorRaw = headerValue(headers, 'author');
432
+ const committerRaw = headerValue(headers, 'committer');
433
+
434
+ return {
435
+ type: 'commit',
436
+ tree,
437
+ parents,
438
+ author: authorRaw ? parseIdentity(authorRaw) : null,
439
+ committer: committerRaw ? parseIdentity(committerRaw) : null,
440
+ encoding: headerValue(headers, 'encoding'),
441
+ extraHeaders: headers.filter(([k]) => !['tree', 'parent', 'author', 'committer'].includes(k)),
442
+ headers,
443
+ message,
444
+ raw: payload
445
+ };
446
+ }
447
+
448
+ // ─── Tags ────────────────────────────────────────────────
449
+
450
+ /**
451
+ * @param {Object} tag
452
+ * @returns {Buffer}
453
+ */
454
+ function serializeTag(tag) {
455
+ if (!OBJECT_TYPES.includes(tag.targetType)) {
456
+ throw new MalformedObjectError(`tag target type '${tag.targetType}' is not an object type`);
457
+ }
458
+ const headers = [
459
+ ['object', assertObjectId(tag.object, 'tag object')],
460
+ ['type', tag.targetType],
461
+ ['tag', tag.tag]
462
+ ];
463
+ if (tag.tagger) headers.push(['tagger', formatIdentity(tag.tagger)]);
464
+ for (const [key, value] of tag.extraHeaders || []) headers.push([key, value]);
465
+
466
+ const message = Buffer.isBuffer(tag.message) ? tag.message : Buffer.from(tag.message || '', 'utf8');
467
+ return serializeHeaderBlock(headers, message);
468
+ }
469
+
470
+ /**
471
+ * @param {Buffer} payload
472
+ * @returns {Object}
473
+ */
474
+ function parseTag(payload) {
475
+ const { headers, message } = parseHeaderBlock(payload);
476
+
477
+ const object = headerValue(headers, 'object');
478
+ const targetType = headerValue(headers, 'type');
479
+ const name = headerValue(headers, 'tag');
480
+ if (!object) throw new MalformedObjectError('tag has no object header');
481
+ assertObjectId(object, 'tag object');
482
+ if (!OBJECT_TYPES.includes(targetType)) {
483
+ throw new MalformedObjectError(`tag type header '${targetType}' is not an object type`);
484
+ }
485
+
486
+ const taggerRaw = headerValue(headers, 'tagger');
487
+ return {
488
+ type: 'tag',
489
+ object,
490
+ targetType,
491
+ tag: name,
492
+ tagger: taggerRaw ? parseIdentity(taggerRaw) : null,
493
+ extraHeaders: headers.filter(([k]) => !['object', 'type', 'tag', 'tagger'].includes(k)),
494
+ headers,
495
+ message,
496
+ raw: payload
497
+ };
498
+ }
499
+
500
+ module.exports = {
501
+ OID_RAW_LENGTH,
502
+ OID_HEX_LENGTH,
503
+ OBJECT_TYPES,
504
+ NULL_OID,
505
+ MODE,
506
+ WRITABLE_MODES,
507
+ MAX_TAG_PEEL_DEPTH,
508
+ MalformedObjectError,
509
+
510
+ isObjectId,
511
+ assertObjectId,
512
+ oidToRaw,
513
+ rawToOid,
514
+
515
+ frameObject,
516
+ hashObject,
517
+ unframeObject,
518
+
519
+ compareTreeEntries,
520
+ assertTreeEntryName,
521
+ serializeTree,
522
+ parseTree,
523
+ modeToType,
524
+
525
+ formatIdentity,
526
+ parseIdentity,
527
+ timezoneOffset,
528
+
529
+ parseHeaderBlock,
530
+ serializeHeaderBlock,
531
+ headerValue,
532
+
533
+ serializeCommit,
534
+ parseCommit,
535
+ serializeTag,
536
+ parseTag
537
+ };