From fbf5b05b67de943e0274c2fa5c46ae6ef42b6f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20=C3=96zhan?= Date: Thu, 1 Oct 2026 16:12:52 +0300 Subject: [PATCH] fix(structure): hash the map in process and time-limit Git calls in the commit hook --- .frame/STRUCTURE.json | 59 +++++++++++++++++++++--------------- scripts/structure-commit.js | 32 ++++++++++++++----- test/structureCommit.test.js | 23 ++++++++++++++ 3 files changed, 82 insertions(+), 32 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index fa33d45f..33fda923 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -1,7 +1,7 @@ { "version": "1.1", "description": "Auto-generated module structure", - "lastUpdated": "2026-09-30", + "lastUpdated": "2026-10-01", "architecture": {}, "modules": { ".claude/settings.json": { @@ -17657,6 +17657,7 @@ "description": "STRUCTURE for a commit — the map built from Git's staged snapshot", "exports": [ "publishStaged", + "blobId", "repairPathspecIndex", "reconcileTrackedMap", "trackedReceiptPath", @@ -17681,13 +17682,13 @@ ], "functions": { "sha256": { - "line": 45, + "line": 46, "params": [ "data" ] }, "git": { - "line": 63, + "line": 64, "params": [ "root", "args", @@ -17695,8 +17696,16 @@ ], "purpose": "Run Git for this checkout and its effective index. `GIT_INDEX_FILE` from" }, + "blobId": { + "line": 90, + "params": [ + "bytes", + "like" + ], + "purpose": "Git's object id for a blob, computed here instead of piping the bytes" + }, "resolveIndex": { - "line": 78, + "line": 96, "params": [ "root", "env = process.env" @@ -17704,7 +17713,7 @@ "purpose": "The index this commit uses: the hook's GIT_INDEX_FILE, else the checkout's." }, "listEntries": { - "line": 88, + "line": 106, "params": [ "root", "env = process.env" @@ -17712,7 +17721,7 @@ "purpose": "Staged entries of the effective index. Any unmerged stage makes the" }, "readBlobs": { - "line": 106, + "line": 124, "params": [ "root", "ids", @@ -17721,20 +17730,20 @@ "purpose": "Read blobs with one `cat-file --batch` process. Returns Map(id → Buffer)." }, "enoent": { - "line": 127, + "line": 145, "params": [ "p" ] }, "fakeStat": { - "line": 133, + "line": 151, "params": [ "kind", "size" ] }, "createIndexFs": { - "line": 150, + "line": 168, "params": [ "root", "entries", @@ -17743,7 +17752,7 @@ "purpose": "A read-only fs over the staged tree, enough for STR-01 discovery and" }, "buildStaged": { - "line": 239, + "line": 257, "params": [ "root", "options = {}" @@ -17751,33 +17760,33 @@ "purpose": "Build the commit's map from the effective index." }, "indexIdentity": { - "line": 329, + "line": 347, "params": [ "indexFile" ] }, "receiptPath": { - "line": 337, + "line": 355, "params": [ "root" ] }, "writeReceipt": { - "line": 341, + "line": 359, "params": [ "root", "value" ] }, "writeJson": { - "line": 345, + "line": 363, "params": [ "file", "value" ] }, "mirrorToDisk": { - "line": 362, + "line": 380, "params": [ "root", "mapPath", @@ -17788,7 +17797,7 @@ "purpose": "Set the tracked map on disk to the commit's map (STR-02c D6), so a normal" }, "repairPathspecIndex": { - "line": 395, + "line": 412, "params": [ "root", "options = {}" @@ -17796,13 +17805,13 @@ "purpose": "A pathspec commit (`git commit -- `) runs the hook on a temporary" }, "trackedReceiptPath": { - "line": 421, + "line": 437, "params": [ "root" ] }, "reconcileTrackedMap": { - "line": 441, + "line": 457, "params": [ "root", "options = {}" @@ -17810,14 +17819,14 @@ "purpose": "Bring the tracked map on disk back to the committed view when that loses" }, "reconcileTracked": { - "line": 453, + "line": 469, "params": [ "root", "env" ] }, "publishStaged": { - "line": 522, + "line": 537, "params": [ "root", "options = {}" @@ -17825,13 +17834,14 @@ "purpose": "Build the commit's map and publish it into the effective index." } }, - "sizeBytes": 22558, + "sizeBytes": 23037, "extraction": { "status": "parsed" }, "provenance": { "description": "e2d9198bdb4c6a16", "purposes": { + "blobId": "300104a3c1e3211a", "buildStaged": "adaf4c93fc0707d5", "createIndexFs": "f35ade5b3634de6c", "git": "98c4595cc6e8df9b", @@ -23570,7 +23580,8 @@ "path", "child_process", "scripts/structure-commit", - "scripts/structure-lifecycle" + "scripts/structure-lifecycle", + "crypto" ], "functions": { "repo": { @@ -23600,7 +23611,7 @@ ] } }, - "sizeBytes": 16049, + "sizeBytes": 17362, "extraction": { "status": "parsed" }, @@ -25959,6 +25970,6 @@ "truncated": false, "samples": [] }, - "revision": "859fa6c387a5b70589dad88c312990b84f705db6989cb690fb37138df146c433" + "revision": "1a4e0e0f7a37dbcee76b8c468106e982c6cbe8b610c1a113e53af64b6b7309d9" } } diff --git a/scripts/structure-commit.js b/scripts/structure-commit.js index 275a328d..efb747e2 100644 --- a/scripts/structure-commit.js +++ b/scripts/structure-commit.js @@ -41,6 +41,7 @@ const snapshot = require('./structure-snapshot'); const MODE_FILE = new Set(['100644', '100755']); const MODE_SYMLINK = '120000'; const MODE_GITLINK = '160000'; +const GIT_TIMEOUT_MS = 60000; function sha256(data) { return crypto.createHash('sha256').update(data).digest('hex'); @@ -65,15 +66,32 @@ function git(root, args, options = {}) { cwd: root, env: options.env || process.env, input: options.input, - maxBuffer: 1024 * 1024 * 1024 + maxBuffer: 1024 * 1024 * 1024, + // a commit hook must never hang the commit: a stuck Git call fails this run + timeout: options.timeout || GIT_TIMEOUT_MS, + killSignal: 'SIGKILL' }); - if (result.error) throw new CommitUnavailable('git-unavailable', result.error.message); + if (result.error) { + const reason = result.error.code === 'ETIMEDOUT' ? 'git-timeout' : 'git-unavailable'; + throw new CommitUnavailable(reason, result.error.message); + } if (result.status !== 0 && !options.allowFailure) { throw new CommitUnavailable(options.failureReason || 'git-failed', String(result.stderr || '').trim().split('\n')[0]); } return result; } +/** + * Git's object id for a blob, computed here instead of piping the bytes + * through `git hash-object --stdin` (no filters apply to either). The hash + * function follows the id it is compared with: 40 hex digits SHA-1, 64 + * SHA-256. + */ +function blobId(bytes, like) { + const algorithm = typeof like === 'string' && like.length === 64 ? 'sha256' : 'sha1'; + return crypto.createHash(algorithm).update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +} + /** The index this commit uses: the hook's GIT_INDEX_FILE, else the checkout's. */ function resolveIndex(root, env = process.env) { if (env.GIT_INDEX_FILE) return path.resolve(root, env.GIT_INDEX_FILE); @@ -371,8 +389,7 @@ function mirrorToDisk(root, mapPath, candidate, stagedId, env) { if (current && current.equals(bytes)) return 'unchanged'; try { if (current) { - const diskId = git(root, ['hash-object', '--stdin'], { env, input: current }).stdout.toString().trim(); - if (diskId !== stagedId) return 'kept'; + if (blobId(current, stagedId) !== stagedId) return 'kept'; } fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; @@ -409,8 +426,7 @@ function repairPathspecIndex(root, options = {}) { } catch (e) { return 'skipped'; } - const diskId = git(root, ['hash-object', '--stdin'], { env, input: disk }).stdout.toString().trim(); - if (diskId !== headId) return 'clean'; + if (blobId(disk, headId) !== headId) return 'clean'; const update = git(root, ['update-index', '--cacheinfo', `${match[1]},${headId},${rel}`], { env, allowFailure: true }); return update.status === 0 ? 'repaired' : 'failed'; } catch (e) { @@ -465,8 +481,7 @@ function reconcileTracked(root, env) { return { status: 'skipped', reason: 'missing' }; } const diskDigest = sha256(disk); - const diskId = git(root, ['hash-object', '--stdin'], { env, input: disk }).stdout.toString().trim(); - if (diskId === match[2]) return { status: 'clean', diskDigest }; + if (blobId(disk, match[2]) === match[2]) return { status: 'clean', diskDigest }; const repair = repairPathspecIndex(root, { env }); if (repair === 'repaired') return { status: 'repaired', diskDigest }; @@ -572,6 +587,7 @@ function publishStaged(root, options = {}) { module.exports = { publishStaged, + blobId, repairPathspecIndex, reconcileTrackedMap, trackedReceiptPath, diff --git a/test/structureCommit.test.js b/test/structureCommit.test.js index e99ba4b9..9099468c 100644 --- a/test/structureCommit.test.js +++ b/test/structureCommit.test.js @@ -348,3 +348,26 @@ test('repairPathspecIndex skips a repository whose HEAD has no map', (t) => { const { dir } = committedRepo(t); assert.equal(commit.repairPathspecIndex(dir), 'skipped'); }); + +/* ------------- a commit hook never hangs, never pipes the map through Git ------------- */ + +test('blobId matches git hash-object for SHA-1 ids and follows a SHA-256 id', (t) => { + const { dir } = repo(t); + for (const bytes of [Buffer.from(''), Buffer.from('{"a":1}\n'), Buffer.from('ç\u0000x'.repeat(5000))]) { + const expected = spawnSync('git', ['hash-object', '--stdin'], { cwd: dir, input: bytes, encoding: 'utf8' }).stdout.trim(); + assert.equal(commit.blobId(bytes, expected), expected); + } + const sha256 = commit.blobId(Buffer.from('x'), 'f'.repeat(64)); + assert.equal(sha256.length, 64); + assert.equal(sha256, require('crypto').createHash('sha256').update('blob 1\0x').digest('hex')); +}); + +test('a Git call that does not return in time fails the run instead of hanging the commit', (t) => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'frame-slow-git-')); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nsleep 5\n', { mode: 0o755 }); + const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + const started = Date.now(); + assert.throws(() => commit.git(bin, ['status'], { env, timeout: 200 }), (err) => err.reason === 'git-timeout'); + assert.ok(Date.now() - started < 3000); +});