@opengsd/gsd-core 1.20.0 → 1.21.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +265 -161
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +554 -823
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +99 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +20 -18
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +20 -28
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-id.cjs +26 -9
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +170 -45
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +103 -46
- package/gsd-core/bin/lib/state.cjs +448 -63
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +168 -13
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +4 -3
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +9 -6
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +168 -13
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
2
|
+
// Read a path's entry out of the commit HEAD names, straight from `.git`.
|
|
3
|
+
//
|
|
4
|
+
// `headTreeEntry(startDir, relPath, limits?)` answers "what mode and object id
|
|
5
|
+
// does the tree of HEAD hold at this path?" without spawning git and without a
|
|
6
|
+
// timer: the verdict depends only on the repository's bytes, never on machine
|
|
7
|
+
// load or the wall clock (#4432).
|
|
8
|
+
//
|
|
9
|
+
// What it reads, in order:
|
|
10
|
+
// 1. the git dir: walk up from startDir to the nearest `.git`; a `.git` FILE
|
|
11
|
+
// (linked worktree, submodule) holds `gitdir: <path>`; a `commondir` file
|
|
12
|
+
// points at the directory that holds the shared objects and refs.
|
|
13
|
+
// 2. HEAD: a `ref:` chain through loose refs, then `packed-refs`, ending at a
|
|
14
|
+
// 40-hex object id (a detached HEAD holds the id directly).
|
|
15
|
+
// 3. objects: a loose object (`objects/xx/yyyy…`, zlib) or an object in a
|
|
16
|
+
// pack (`objects/pack/*.idx` version 2 + `.pack`, including OFS_DELTA and
|
|
17
|
+
// REF_DELTA chains).
|
|
18
|
+
// 4. the commit's `tree`, then each path segment through nested trees.
|
|
19
|
+
//
|
|
20
|
+
// Fail closed: ANY problem — no repository, no commits, a SHA-256 repository,
|
|
21
|
+
// a missing object (shallow/partial clone, objects only in alternates), an
|
|
22
|
+
// unknown index version, corruption, a structural limit — returns null. This
|
|
23
|
+
// module never throws and never answers from a guess. It is a pure reader: it
|
|
24
|
+
// spawns nothing and sets no timer, so every bound below is structural (a
|
|
25
|
+
// count or a byte size), not a duration.
|
|
26
|
+
//
|
|
27
|
+
// Standalone on purpose (node built-ins `fs`, `path`, `zlib` only): hooks are
|
|
28
|
+
// installed as loose files and cannot require `src/`.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const zlib = require('zlib');
|
|
35
|
+
|
|
36
|
+
const LIMITS = Object.freeze({
|
|
37
|
+
maxRefHops: 8,
|
|
38
|
+
maxParentDirs: 64,
|
|
39
|
+
maxObjectBytes: 1024 * 1024,
|
|
40
|
+
maxDeltaDepth: 50,
|
|
41
|
+
maxRefsFileBytes: 4 * 1024 * 1024,
|
|
42
|
+
maxPackFiles: 64,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
46
|
+
|
|
47
|
+
// Internal bail-out: caught once at the entry point and turned into null.
|
|
48
|
+
class Unreadable extends Error {}
|
|
49
|
+
|
|
50
|
+
function fail(why) {
|
|
51
|
+
throw new Unreadable(why);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveLimits(limits) {
|
|
55
|
+
const out = { ...LIMITS };
|
|
56
|
+
if (limits !== null && typeof limits === 'object') {
|
|
57
|
+
for (const key of Object.keys(LIMITS)) {
|
|
58
|
+
const v = limits[key];
|
|
59
|
+
if (Number.isInteger(v) && v >= 0) out[key] = v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── bounded file reads ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function readExactAt(fd, pos, len) {
|
|
68
|
+
const buf = Buffer.alloc(len);
|
|
69
|
+
const n = fs.readSync(fd, buf, 0, len, pos);
|
|
70
|
+
if (n !== len) fail('short read');
|
|
71
|
+
return buf;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readFileBounded(file, maxBytes) {
|
|
75
|
+
const fd = fs.openSync(file, 'r');
|
|
76
|
+
try {
|
|
77
|
+
const st = fs.fstatSync(fd);
|
|
78
|
+
if (!st.isFile()) fail('not a regular file');
|
|
79
|
+
if (st.size > maxBytes) fail('file too large');
|
|
80
|
+
return st.size === 0 ? Buffer.alloc(0) : readExactAt(fd, 0, st.size);
|
|
81
|
+
} finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readTextOrNull(file, maxBytes) {
|
|
87
|
+
try {
|
|
88
|
+
return readFileBounded(file, maxBytes).toString('utf8');
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof Unreadable) throw err;
|
|
91
|
+
return null; // missing or not a file: the caller decides what that means
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── locating the repository ────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
// realpathSync.native, not the JS realpath: on Windows the JS form leaves an
|
|
98
|
+
// 8.3 short name (RUNNER~1) as written, so the same directory reached through a
|
|
99
|
+
// short and a long name compared unequal and a real linked worktree was refused.
|
|
100
|
+
function sameRealPath(a, b) {
|
|
101
|
+
try {
|
|
102
|
+
return fs.realpathSync.native(a) === fs.realpathSync.native(b);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A `.git` file is followed only when it is the one `git worktree add` wrote:
|
|
109
|
+
// its gitdir is <commondir>/worktrees/<name> and that directory's `gitdir`
|
|
110
|
+
// back-pointer names this very `.git` file. A planted file fails one of the two.
|
|
111
|
+
function linkedWorktree(dir, dotGit, gitDir) {
|
|
112
|
+
const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
|
|
113
|
+
if (common === null || common.trim() === '') fail('worktree git dir has no commondir');
|
|
114
|
+
const commonDir = path.resolve(gitDir, common.trim());
|
|
115
|
+
if (!sameRealPath(path.dirname(gitDir), path.join(commonDir, 'worktrees'))) fail('git dir is not a linked worktree');
|
|
116
|
+
const back = readTextOrNull(path.join(gitDir, 'gitdir'), 4096);
|
|
117
|
+
if (back === null || back.trim() === '') fail('worktree git dir has no back-pointer');
|
|
118
|
+
if (!sameRealPath(path.resolve(gitDir, back.trim()), dotGit)) fail('worktree back-pointer names another .git file');
|
|
119
|
+
return { top: dir, gitDir, commonDir };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function locateGit(startDir, limits) {
|
|
123
|
+
let dir = path.resolve(startDir);
|
|
124
|
+
for (let i = 0; i <= limits.maxParentDirs; i++) {
|
|
125
|
+
const dotGit = path.join(dir, '.git');
|
|
126
|
+
let st = null;
|
|
127
|
+
try {
|
|
128
|
+
st = fs.statSync(dotGit);
|
|
129
|
+
} catch {
|
|
130
|
+
st = null;
|
|
131
|
+
}
|
|
132
|
+
if (st !== null) {
|
|
133
|
+
const gitDir = dotGit;
|
|
134
|
+
if (st.isFile()) {
|
|
135
|
+
const text = readTextOrNull(dotGit, 4096);
|
|
136
|
+
const m = text === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(text);
|
|
137
|
+
if (m === null) fail('unreadable .git file');
|
|
138
|
+
return linkedWorktree(dir, dotGit, path.resolve(dir, m[1]));
|
|
139
|
+
} else if (!st.isDirectory()) {
|
|
140
|
+
fail('unexpected .git entry');
|
|
141
|
+
}
|
|
142
|
+
let commonDir = gitDir;
|
|
143
|
+
const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
|
|
144
|
+
if (common !== null && common.trim() !== '') commonDir = path.resolve(gitDir, common.trim());
|
|
145
|
+
return { top: dir, gitDir, commonDir };
|
|
146
|
+
}
|
|
147
|
+
const parent = path.dirname(dir);
|
|
148
|
+
if (parent === dir) break;
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
return fail('no repository');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assertSha1Repository(commonDir, limits) {
|
|
155
|
+
const config = readTextOrNull(path.join(commonDir, 'config'), limits.maxRefsFileBytes);
|
|
156
|
+
if (config !== null && /^\s*objectformat\s*=\s*sha256\s*$/im.test(config)) fail('sha256 repository');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── refs ───────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function isSafeRefName(name) {
|
|
162
|
+
return name !== '' && !name.startsWith('/') && !name.includes('\0') && !name.includes('\\') &&
|
|
163
|
+
!name.split('/').some((seg) => seg === '' || seg === '.' || seg === '..');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// packed-refs is read at most once per repository per call, however many ref hops follow.
|
|
167
|
+
function readPackedRefs(base, repo, limits) {
|
|
168
|
+
if (repo.packedRefsCache === undefined) repo.packedRefsCache = new Map();
|
|
169
|
+
if (!repo.packedRefsCache.has(base)) {
|
|
170
|
+
repo.packedRefsCache.set(base, readTextOrNull(path.join(base, 'packed-refs'), limits.maxRefsFileBytes));
|
|
171
|
+
}
|
|
172
|
+
return repo.packedRefsCache.get(base);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function lookupRef(name, repo, limits) {
|
|
176
|
+
if (!isSafeRefName(name)) fail('bad ref name');
|
|
177
|
+
for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.gitDir, repo.commonDir]) {
|
|
178
|
+
const loose = readTextOrNull(path.join(base, ...name.split('/')), 4096);
|
|
179
|
+
if (loose !== null && loose.trim() !== '') return loose.trim();
|
|
180
|
+
}
|
|
181
|
+
for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.commonDir, repo.gitDir]) {
|
|
182
|
+
const packed = readPackedRefs(base, repo, limits);
|
|
183
|
+
if (packed === null) continue;
|
|
184
|
+
for (const line of packed.split('\n')) {
|
|
185
|
+
if (line === '' || line[0] === '#' || line[0] === '^') continue;
|
|
186
|
+
const space = line.indexOf(' ');
|
|
187
|
+
if (space === 40 && line.slice(space + 1).trim() === name) return line.slice(0, 40);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return fail('ref not found');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveHead(repo, limits) {
|
|
194
|
+
const head = readTextOrNull(path.join(repo.gitDir, 'HEAD'), 4096);
|
|
195
|
+
if (head === null) fail('no HEAD');
|
|
196
|
+
let content = head.trim();
|
|
197
|
+
let hops = 0;
|
|
198
|
+
while (content.startsWith('ref:')) {
|
|
199
|
+
hops++;
|
|
200
|
+
if (hops > limits.maxRefHops) fail('too many ref hops');
|
|
201
|
+
content = lookupRef(content.slice(4).trim(), repo, limits);
|
|
202
|
+
}
|
|
203
|
+
if (!HEX40.test(content)) fail('HEAD does not name a SHA-1 object');
|
|
204
|
+
return content;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── objects ────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const TYPE_NAMES = { 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag' };
|
|
210
|
+
|
|
211
|
+
// Worst-case deflate expansion is 5 bytes per 64 KiB block plus a few bytes of
|
|
212
|
+
// framing; this bound is only a memory cap on how much compressed input to read.
|
|
213
|
+
function compressedCap(inflatedBytes) {
|
|
214
|
+
return inflatedBytes + (inflatedBytes >> 8) + 64;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readLoose(oid, repo, limits) {
|
|
218
|
+
const file = path.join(repo.commonDir, 'objects', oid.slice(0, 2), oid.slice(2));
|
|
219
|
+
let raw;
|
|
220
|
+
try {
|
|
221
|
+
raw = readFileBounded(file, compressedCap(limits.maxObjectBytes));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof Unreadable) throw err;
|
|
224
|
+
if (err && err.code === 'ENOENT') return null;
|
|
225
|
+
return fail('unreadable loose object');
|
|
226
|
+
}
|
|
227
|
+
const inflated = zlib.inflateSync(raw, { maxOutputLength: limits.maxObjectBytes });
|
|
228
|
+
const nul = inflated.indexOf(0);
|
|
229
|
+
if (nul === -1) fail('loose object has no header');
|
|
230
|
+
const m = /^(commit|tree|blob|tag) (\d+)$/.exec(inflated.toString('latin1', 0, nul));
|
|
231
|
+
if (m === null) fail('bad loose object header');
|
|
232
|
+
const data = inflated.subarray(nul + 1);
|
|
233
|
+
if (data.length !== Number(m[2])) fail('loose object size mismatch');
|
|
234
|
+
return { type: m[1], data, depth: 0 };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function applyDelta(base, delta, limits) {
|
|
238
|
+
let pos = 0;
|
|
239
|
+
const varint = () => {
|
|
240
|
+
let value = 0;
|
|
241
|
+
let shift = 0;
|
|
242
|
+
for (;;) {
|
|
243
|
+
if (pos >= delta.length) fail('truncated delta');
|
|
244
|
+
const b = delta[pos++];
|
|
245
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
246
|
+
shift += 7;
|
|
247
|
+
if ((b & 0x80) === 0) return value;
|
|
248
|
+
if (shift > 56) fail('delta size overflow');
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const srcSize = varint();
|
|
252
|
+
const dstSize = varint();
|
|
253
|
+
if (srcSize !== base.length) fail('delta base size mismatch');
|
|
254
|
+
if (dstSize > limits.maxObjectBytes) fail('delta result too large');
|
|
255
|
+
const out = Buffer.alloc(dstSize);
|
|
256
|
+
let outPos = 0;
|
|
257
|
+
while (pos < delta.length) {
|
|
258
|
+
const cmd = delta[pos++];
|
|
259
|
+
if ((cmd & 0x80) !== 0) {
|
|
260
|
+
let offset = 0;
|
|
261
|
+
let size = 0;
|
|
262
|
+
for (let i = 0; i < 4; i++) {
|
|
263
|
+
if ((cmd & (1 << i)) !== 0) {
|
|
264
|
+
if (pos >= delta.length) fail('truncated delta copy');
|
|
265
|
+
offset += delta[pos++] * 2 ** (8 * i);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (let i = 0; i < 3; i++) {
|
|
269
|
+
if ((cmd & (0x10 << i)) !== 0) {
|
|
270
|
+
if (pos >= delta.length) fail('truncated delta copy');
|
|
271
|
+
size += delta[pos++] * 2 ** (8 * i);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (size === 0) size = 0x10000;
|
|
275
|
+
if (offset + size > base.length || outPos + size > dstSize) fail('delta copy out of range');
|
|
276
|
+
base.copy(out, outPos, offset, offset + size);
|
|
277
|
+
outPos += size;
|
|
278
|
+
} else if (cmd !== 0) {
|
|
279
|
+
if (pos + cmd > delta.length || outPos + cmd > dstSize) fail('delta insert out of range');
|
|
280
|
+
delta.copy(out, outPos, pos, pos + cmd);
|
|
281
|
+
pos += cmd;
|
|
282
|
+
outPos += cmd;
|
|
283
|
+
} else {
|
|
284
|
+
fail('reserved delta opcode');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (outPos !== dstSize) fail('delta result size mismatch');
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function packEntry(packFd, packSize, offset, level, repo, limits) {
|
|
292
|
+
if (level > limits.maxDeltaDepth) fail('delta chain too deep');
|
|
293
|
+
if (offset < 12 || offset >= packSize) fail('pack offset out of range');
|
|
294
|
+
const header = readExactAt(packFd, offset, Math.min(32, packSize - offset));
|
|
295
|
+
let pos = 0;
|
|
296
|
+
let byte = header[pos++];
|
|
297
|
+
const typeCode = (byte >> 4) & 7;
|
|
298
|
+
let size = byte & 0x0f;
|
|
299
|
+
let shift = 4;
|
|
300
|
+
while ((byte & 0x80) !== 0) {
|
|
301
|
+
if (pos >= header.length || shift > 56) fail('bad pack entry header');
|
|
302
|
+
byte = header[pos++];
|
|
303
|
+
size += (byte & 0x7f) * 2 ** shift;
|
|
304
|
+
shift += 7;
|
|
305
|
+
}
|
|
306
|
+
if (size > limits.maxObjectBytes) fail('pack object too large');
|
|
307
|
+
|
|
308
|
+
let baseOffset = -1;
|
|
309
|
+
let baseOid = null;
|
|
310
|
+
if (typeCode === 6) {
|
|
311
|
+
if (pos >= header.length) fail('bad ofs-delta header');
|
|
312
|
+
byte = header[pos++];
|
|
313
|
+
let rel = byte & 0x7f;
|
|
314
|
+
while ((byte & 0x80) !== 0) {
|
|
315
|
+
if (pos >= header.length) fail('bad ofs-delta header');
|
|
316
|
+
byte = header[pos++];
|
|
317
|
+
rel = (rel + 1) * 128 + (byte & 0x7f);
|
|
318
|
+
}
|
|
319
|
+
baseOffset = offset - rel;
|
|
320
|
+
} else if (typeCode === 7) {
|
|
321
|
+
if (pos + 20 > header.length) fail('bad ref-delta header');
|
|
322
|
+
baseOid = header.toString('hex', pos, pos + 20);
|
|
323
|
+
pos += 20;
|
|
324
|
+
} else if (TYPE_NAMES[typeCode] === undefined) {
|
|
325
|
+
fail('unknown pack object type');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const start = offset + pos;
|
|
329
|
+
const want = Math.min(compressedCap(size), packSize - start);
|
|
330
|
+
if (want <= 0) fail('pack entry has no data');
|
|
331
|
+
const inflated = zlib.inflateSync(readExactAt(packFd, start, want), { maxOutputLength: Math.max(size, 1) });
|
|
332
|
+
if (inflated.length !== size) fail('pack entry size mismatch');
|
|
333
|
+
|
|
334
|
+
if (typeCode !== 6 && typeCode !== 7) return { type: TYPE_NAMES[typeCode], data: inflated, depth: 0 };
|
|
335
|
+
|
|
336
|
+
let base;
|
|
337
|
+
if (typeCode === 6) {
|
|
338
|
+
if (baseOffset < 12) fail('bad ofs-delta base');
|
|
339
|
+
base = packEntry(packFd, packSize, baseOffset, level + 1, repo, limits);
|
|
340
|
+
} else {
|
|
341
|
+
base = readObject(baseOid, repo, limits, level + 1);
|
|
342
|
+
}
|
|
343
|
+
return { type: base.type, data: applyDelta(base.data, inflated, limits), depth: base.depth + 1 };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function idxLookup(idxFd, idxSize, oid) {
|
|
347
|
+
const head = readExactAt(idxFd, 0, 8 + 1024);
|
|
348
|
+
if (head.readUInt32BE(0) !== 0xff744f63 || head.readUInt32BE(4) !== 2) fail('unsupported pack index');
|
|
349
|
+
const fan = (i) => head.readUInt32BE(8 + 4 * i);
|
|
350
|
+
const count = fan(255);
|
|
351
|
+
if (idxSize < 8 + 1024 + count * 28 + 40) fail('truncated pack index');
|
|
352
|
+
const want = Buffer.from(oid, 'hex');
|
|
353
|
+
let lo = want[0] === 0 ? 0 : fan(want[0] - 1);
|
|
354
|
+
let hi = fan(want[0]);
|
|
355
|
+
if (lo > hi || hi > count) fail('corrupt pack index fanout');
|
|
356
|
+
while (lo < hi) {
|
|
357
|
+
const mid = (lo + hi) >>> 1;
|
|
358
|
+
const cmp = Buffer.compare(readExactAt(idxFd, 1032 + mid * 20, 20), want);
|
|
359
|
+
if (cmp === 0) {
|
|
360
|
+
const off32 = readExactAt(idxFd, 1032 + count * 24 + mid * 4, 4).readUInt32BE(0);
|
|
361
|
+
if ((off32 & 0x80000000) === 0) return off32;
|
|
362
|
+
const slot = off32 & 0x7fffffff;
|
|
363
|
+
if (idxSize < 1032 + count * 28 + slot * 8 + 8 + 40) fail('truncated pack index');
|
|
364
|
+
const big = readExactAt(idxFd, 1032 + count * 28 + slot * 8, 8).readBigUInt64BE(0);
|
|
365
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) fail('pack offset too large');
|
|
366
|
+
return Number(big);
|
|
367
|
+
}
|
|
368
|
+
if (cmp < 0) lo = mid + 1;
|
|
369
|
+
else hi = mid;
|
|
370
|
+
}
|
|
371
|
+
return -1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readPacked(oid, repo, limits, level) {
|
|
375
|
+
const packDir = path.join(repo.commonDir, 'objects', 'pack');
|
|
376
|
+
let names;
|
|
377
|
+
try {
|
|
378
|
+
names = fs.readdirSync(packDir).filter((n) => n.endsWith('.idx')).sort();
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
if (names.length > limits.maxPackFiles) fail('too many pack files');
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
const idxFd = fs.openSync(path.join(packDir, name), 'r');
|
|
385
|
+
try {
|
|
386
|
+
const offset = idxLookup(idxFd, fs.fstatSync(idxFd).size, oid);
|
|
387
|
+
if (offset === -1) continue;
|
|
388
|
+
const packFd = fs.openSync(path.join(packDir, `${name.slice(0, -4)}.pack`), 'r');
|
|
389
|
+
try {
|
|
390
|
+
return packEntry(packFd, fs.fstatSync(packFd).size, offset, level, repo, limits);
|
|
391
|
+
} finally {
|
|
392
|
+
fs.closeSync(packFd);
|
|
393
|
+
}
|
|
394
|
+
} finally {
|
|
395
|
+
fs.closeSync(idxFd);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readObject(oid, repo, limits, level) {
|
|
402
|
+
if (!HEX40.test(oid)) fail('bad object id');
|
|
403
|
+
if (level > limits.maxDeltaDepth) fail('delta chain too deep');
|
|
404
|
+
const loose = readLoose(oid, repo, limits);
|
|
405
|
+
if (loose !== null) return loose;
|
|
406
|
+
const packed = readPacked(oid, repo, limits, level);
|
|
407
|
+
if (packed !== null) return packed;
|
|
408
|
+
return fail('object not found');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ─── trees ──────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function findInTree(data, name) {
|
|
414
|
+
const want = Buffer.from(name, 'utf8');
|
|
415
|
+
let pos = 0;
|
|
416
|
+
while (pos < data.length) {
|
|
417
|
+
const space = data.indexOf(0x20, pos);
|
|
418
|
+
const nul = space === -1 ? -1 : data.indexOf(0, space + 1);
|
|
419
|
+
if (space === -1 || nul === -1 || nul + 21 > data.length) fail('corrupt tree');
|
|
420
|
+
if (data.subarray(space + 1, nul).equals(want)) {
|
|
421
|
+
return { mode: data.toString('latin1', pos, space), oid: data.toString('hex', nul + 1, nul + 21) };
|
|
422
|
+
}
|
|
423
|
+
pos = nul + 21;
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function lookup(startDir, relPath, limits) {
|
|
429
|
+
if (typeof startDir !== 'string' || startDir === '' || typeof relPath !== 'string' || relPath === '') return null;
|
|
430
|
+
if (startDir.includes('\0') || relPath.includes('\0')) return null;
|
|
431
|
+
const repo = locateGit(startDir, limits);
|
|
432
|
+
const rel = path.relative(repo.top, path.resolve(startDir, relPath));
|
|
433
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; // allow-handrolled-containment: an installed hook cannot require src/security.cts
|
|
434
|
+
const segments = rel.split(path.sep);
|
|
435
|
+
if (segments.some((seg) => seg === '' || seg === '.' || seg === '..')) return null;
|
|
436
|
+
|
|
437
|
+
assertSha1Repository(repo.commonDir, limits);
|
|
438
|
+
const commit = readObject(resolveHead(repo, limits), repo, limits, 0);
|
|
439
|
+
if (commit.type !== 'commit') fail('HEAD is not a commit');
|
|
440
|
+
const m = /^tree ([0-9a-f]{40})\n/.exec(commit.data.toString('latin1', 0, Math.min(commit.data.length, 64)));
|
|
441
|
+
if (m === null) fail('commit has no tree');
|
|
442
|
+
|
|
443
|
+
let treeOid = m[1];
|
|
444
|
+
for (let i = 0; i < segments.length; i++) {
|
|
445
|
+
const tree = readObject(treeOid, repo, limits, 0);
|
|
446
|
+
if (tree.type !== 'tree') fail('not a tree');
|
|
447
|
+
const entry = findInTree(tree.data, segments[i]);
|
|
448
|
+
if (entry === null) return null;
|
|
449
|
+
if (i === segments.length - 1) return entry;
|
|
450
|
+
if (entry.mode !== '40000') return null;
|
|
451
|
+
treeOid = entry.oid;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* @param {string} startDir directory the walk up to `.git` starts from
|
|
458
|
+
* @param {string} relPath path relative to startDir (a path below the repo top)
|
|
459
|
+
* @param {Partial<typeof LIMITS>} [limits] structural bounds, for tests
|
|
460
|
+
* @returns {{mode: string, oid: string} | null} the tree entry HEAD holds at the
|
|
461
|
+
* path, or null when it is absent or the repository cannot be read with confidence
|
|
462
|
+
*/
|
|
463
|
+
function headTreeEntry(startDir, relPath, limits) {
|
|
464
|
+
try {
|
|
465
|
+
return lookup(startDir, relPath, resolveLimits(limits));
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Exposed so tests can drive the parsers with hand-built buffers; not a public API.
|
|
472
|
+
const _internal = { applyDelta, idxLookup, packEntry, lookupRef, locateGit, resolveLimits };
|
|
473
|
+
|
|
474
|
+
module.exports = { headTreeEntry, LIMITS, _internal };
|