@intx/hub-api 0.1.2
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/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions";
|
|
3
|
+
|
|
4
|
+
import { handleReceivePack } from "./receive-pack";
|
|
5
|
+
|
|
6
|
+
const REPO_ID: RepoId = { kind: "agent-state", id: "test" };
|
|
7
|
+
const ZERO_OID = "0".repeat(40);
|
|
8
|
+
|
|
9
|
+
function hex4(n: number): string {
|
|
10
|
+
return n.toString(16).padStart(4, "0");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pkt(payload: string): Uint8Array {
|
|
14
|
+
const enc = new TextEncoder().encode(payload);
|
|
15
|
+
const header = new TextEncoder().encode(hex4(enc.length + 4));
|
|
16
|
+
const out = new Uint8Array(header.length + enc.length);
|
|
17
|
+
out.set(header, 0);
|
|
18
|
+
out.set(enc, header.length);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FLUSH = new TextEncoder().encode("0000");
|
|
23
|
+
|
|
24
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
25
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
26
|
+
const out = new Uint8Array(total);
|
|
27
|
+
let off = 0;
|
|
28
|
+
for (const p of parts) {
|
|
29
|
+
out.set(p, off);
|
|
30
|
+
off += p.length;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildBody(
|
|
36
|
+
commands: { oldSha: string; newSha: string; ref: string }[],
|
|
37
|
+
pack: Uint8Array,
|
|
38
|
+
capabilities?: string,
|
|
39
|
+
): Uint8Array {
|
|
40
|
+
if (commands.length === 0) {
|
|
41
|
+
throw new Error("buildBody: need at least one command");
|
|
42
|
+
}
|
|
43
|
+
const parts: Uint8Array[] = [];
|
|
44
|
+
for (let i = 0; i < commands.length; i++) {
|
|
45
|
+
const cmd = commands[i];
|
|
46
|
+
if (cmd === undefined) {
|
|
47
|
+
throw new Error("unreachable");
|
|
48
|
+
}
|
|
49
|
+
let line = `${cmd.oldSha} ${cmd.newSha} ${cmd.ref}`;
|
|
50
|
+
if (i === 0 && capabilities !== undefined) {
|
|
51
|
+
line += `\0${capabilities}`;
|
|
52
|
+
}
|
|
53
|
+
line += "\n";
|
|
54
|
+
parts.push(pkt(line));
|
|
55
|
+
}
|
|
56
|
+
parts.push(FLUSH);
|
|
57
|
+
parts.push(pack);
|
|
58
|
+
return concat(...parts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildRequest(body: Uint8Array): Request {
|
|
62
|
+
return new Request("http://hub.test/repo/git-receive-pack", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/x-git-receive-pack-request" },
|
|
65
|
+
body,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readBody(response: Response): Promise<string> {
|
|
70
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
71
|
+
return new TextDecoder().decode(buf);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PRINCIPAL: Principal = { kind: "user" };
|
|
75
|
+
|
|
76
|
+
type ReceivePackCall = {
|
|
77
|
+
ref: string;
|
|
78
|
+
newSha: string;
|
|
79
|
+
expectedOldSha: string | null;
|
|
80
|
+
packLength: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type ReceivePackStub = (call: ReceivePackCall) => Promise<void> | void;
|
|
84
|
+
|
|
85
|
+
function createStubStore(stub: ReceivePackStub): {
|
|
86
|
+
store: RepoStore;
|
|
87
|
+
calls: ReceivePackCall[];
|
|
88
|
+
} {
|
|
89
|
+
const calls: ReceivePackCall[] = [];
|
|
90
|
+
const store: RepoStore = {
|
|
91
|
+
initRepo: async () => {
|
|
92
|
+
throw new Error("initRepo: not used in this test");
|
|
93
|
+
},
|
|
94
|
+
writeTree: async () => {
|
|
95
|
+
throw new Error("writeTree: not used in this test");
|
|
96
|
+
},
|
|
97
|
+
createPack: async () => {
|
|
98
|
+
throw new Error("createPack: not used in this test");
|
|
99
|
+
},
|
|
100
|
+
resolveRef: async () => {
|
|
101
|
+
throw new Error("resolveRef: not used in this test");
|
|
102
|
+
},
|
|
103
|
+
listRefs: async () => {
|
|
104
|
+
throw new Error("listRefs: not used in this test");
|
|
105
|
+
},
|
|
106
|
+
resolveHead: async () => {
|
|
107
|
+
throw new Error("resolveHead: not used in this test");
|
|
108
|
+
},
|
|
109
|
+
getRepoDir: () => {
|
|
110
|
+
throw new Error("getRepoDir: not used in this test");
|
|
111
|
+
},
|
|
112
|
+
receivePack: async (
|
|
113
|
+
_principal: Principal,
|
|
114
|
+
_repoId: RepoId,
|
|
115
|
+
ref: string,
|
|
116
|
+
pack: Uint8Array,
|
|
117
|
+
commitSha: string,
|
|
118
|
+
expectedOldSha: string | null,
|
|
119
|
+
) => {
|
|
120
|
+
const call: ReceivePackCall = {
|
|
121
|
+
ref,
|
|
122
|
+
newSha: commitSha,
|
|
123
|
+
expectedOldSha,
|
|
124
|
+
packLength: pack.length,
|
|
125
|
+
};
|
|
126
|
+
calls.push(call);
|
|
127
|
+
await stub(call);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return { store, calls };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PACK_BYTES = new Uint8Array([
|
|
134
|
+
0x50, 0x41, 0x43, 0x4b, 0x00, 0x00, 0x00, 0x02,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
describe("handleReceivePack response framing", () => {
|
|
138
|
+
test("Content-Type is application/x-git-receive-pack-result", async () => {
|
|
139
|
+
const { store } = createStubStore(() => undefined);
|
|
140
|
+
const body = buildBody(
|
|
141
|
+
[
|
|
142
|
+
{
|
|
143
|
+
oldSha: ZERO_OID,
|
|
144
|
+
newSha: "a".repeat(40),
|
|
145
|
+
ref: "refs/heads/main",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
PACK_BYTES,
|
|
149
|
+
"report-status side-band-64k",
|
|
150
|
+
);
|
|
151
|
+
const response = await handleReceivePack(
|
|
152
|
+
store,
|
|
153
|
+
PRINCIPAL,
|
|
154
|
+
REPO_ID,
|
|
155
|
+
buildRequest(body),
|
|
156
|
+
);
|
|
157
|
+
expect(response.headers.get("content-type")).toBe(
|
|
158
|
+
"application/x-git-receive-pack-result",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("handleReceivePack single-ref happy path", () => {
|
|
164
|
+
test("emits unpack ok and ok <ref>", async () => {
|
|
165
|
+
const { store, calls } = createStubStore(() => undefined);
|
|
166
|
+
const newSha = "1".repeat(40);
|
|
167
|
+
const body = buildBody(
|
|
168
|
+
[{ oldSha: ZERO_OID, newSha, ref: "refs/heads/main" }],
|
|
169
|
+
PACK_BYTES,
|
|
170
|
+
"report-status",
|
|
171
|
+
);
|
|
172
|
+
const response = await handleReceivePack(
|
|
173
|
+
store,
|
|
174
|
+
PRINCIPAL,
|
|
175
|
+
REPO_ID,
|
|
176
|
+
buildRequest(body),
|
|
177
|
+
);
|
|
178
|
+
const text = await readBody(response);
|
|
179
|
+
const expected =
|
|
180
|
+
pktText("unpack ok\n") + pktText("ok refs/heads/main\n") + "0000";
|
|
181
|
+
expect(text).toBe(expected);
|
|
182
|
+
expect(calls).toEqual([
|
|
183
|
+
{
|
|
184
|
+
ref: "refs/heads/main",
|
|
185
|
+
newSha,
|
|
186
|
+
expectedOldSha: null,
|
|
187
|
+
packLength: PACK_BYTES.length,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("non-zero oldSha is forwarded as expectedOldSha", async () => {
|
|
193
|
+
const { store, calls } = createStubStore(() => undefined);
|
|
194
|
+
const oldSha = "a".repeat(40);
|
|
195
|
+
const newSha = "b".repeat(40);
|
|
196
|
+
const body = buildBody(
|
|
197
|
+
[{ oldSha, newSha, ref: "refs/heads/main" }],
|
|
198
|
+
PACK_BYTES,
|
|
199
|
+
"report-status",
|
|
200
|
+
);
|
|
201
|
+
await handleReceivePack(store, PRINCIPAL, REPO_ID, buildRequest(body));
|
|
202
|
+
expect(calls[0]?.expectedOldSha).toBe(oldSha);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("handleReceivePack multi-ref sequence", () => {
|
|
207
|
+
test("reports per-ref status in the order received", async () => {
|
|
208
|
+
const { store } = createStubStore(() => undefined);
|
|
209
|
+
const newShaA = "a".repeat(40);
|
|
210
|
+
const newShaB = "b".repeat(40);
|
|
211
|
+
const body = buildBody(
|
|
212
|
+
[
|
|
213
|
+
{ oldSha: ZERO_OID, newSha: newShaA, ref: "refs/heads/main" },
|
|
214
|
+
{ oldSha: ZERO_OID, newSha: newShaB, ref: "refs/heads/dev" },
|
|
215
|
+
],
|
|
216
|
+
PACK_BYTES,
|
|
217
|
+
"report-status",
|
|
218
|
+
);
|
|
219
|
+
const response = await handleReceivePack(
|
|
220
|
+
store,
|
|
221
|
+
PRINCIPAL,
|
|
222
|
+
REPO_ID,
|
|
223
|
+
buildRequest(body),
|
|
224
|
+
);
|
|
225
|
+
const text = await readBody(response);
|
|
226
|
+
const expected =
|
|
227
|
+
pktText("unpack ok\n") +
|
|
228
|
+
pktText("ok refs/heads/main\n") +
|
|
229
|
+
pktText("ok refs/heads/dev\n") +
|
|
230
|
+
"0000";
|
|
231
|
+
expect(text).toBe(expected);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("partial success produces mixed ok/ng status", async () => {
|
|
235
|
+
const { store } = createStubStore((call) => {
|
|
236
|
+
if (call.ref === "refs/heads/dev") {
|
|
237
|
+
throw new Error("path_violation: dev tree not allowed");
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const newShaA = "a".repeat(40);
|
|
241
|
+
const newShaB = "b".repeat(40);
|
|
242
|
+
const body = buildBody(
|
|
243
|
+
[
|
|
244
|
+
{ oldSha: ZERO_OID, newSha: newShaA, ref: "refs/heads/main" },
|
|
245
|
+
{ oldSha: ZERO_OID, newSha: newShaB, ref: "refs/heads/dev" },
|
|
246
|
+
],
|
|
247
|
+
PACK_BYTES,
|
|
248
|
+
"report-status",
|
|
249
|
+
);
|
|
250
|
+
const response = await handleReceivePack(
|
|
251
|
+
store,
|
|
252
|
+
PRINCIPAL,
|
|
253
|
+
REPO_ID,
|
|
254
|
+
buildRequest(body),
|
|
255
|
+
);
|
|
256
|
+
const text = await readBody(response);
|
|
257
|
+
const expected =
|
|
258
|
+
pktText("unpack ok\n") +
|
|
259
|
+
pktText("ok refs/heads/main\n") +
|
|
260
|
+
pktText("ng refs/heads/dev path-violation: dev tree not allowed\n") +
|
|
261
|
+
"0000";
|
|
262
|
+
expect(text).toBe(expected);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("handleReceivePack substrate error translations", () => {
|
|
267
|
+
test("non_fast_forward translates to ng <ref> non-fast-forward", async () => {
|
|
268
|
+
const { store } = createStubStore(() => {
|
|
269
|
+
throw new Error("non_fast_forward: stale oldSha");
|
|
270
|
+
});
|
|
271
|
+
const oldSha = "a".repeat(40);
|
|
272
|
+
const newSha = "b".repeat(40);
|
|
273
|
+
const body = buildBody(
|
|
274
|
+
[{ oldSha, newSha, ref: "refs/heads/main" }],
|
|
275
|
+
PACK_BYTES,
|
|
276
|
+
"report-status",
|
|
277
|
+
);
|
|
278
|
+
const response = await handleReceivePack(
|
|
279
|
+
store,
|
|
280
|
+
PRINCIPAL,
|
|
281
|
+
REPO_ID,
|
|
282
|
+
buildRequest(body),
|
|
283
|
+
);
|
|
284
|
+
const text = await readBody(response);
|
|
285
|
+
const expected =
|
|
286
|
+
pktText("unpack ok\n") +
|
|
287
|
+
pktText("ng refs/heads/main non-fast-forward\n") +
|
|
288
|
+
"0000";
|
|
289
|
+
expect(text).toBe(expected);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("path_violation translates byte-correct with reason", async () => {
|
|
293
|
+
const { store } = createStubStore(() => {
|
|
294
|
+
throw new Error("path_violation: foo not under skill/");
|
|
295
|
+
});
|
|
296
|
+
const body = buildBody(
|
|
297
|
+
[
|
|
298
|
+
{
|
|
299
|
+
oldSha: ZERO_OID,
|
|
300
|
+
newSha: "a".repeat(40),
|
|
301
|
+
ref: "refs/heads/main",
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
PACK_BYTES,
|
|
305
|
+
"report-status",
|
|
306
|
+
);
|
|
307
|
+
const response = await handleReceivePack(
|
|
308
|
+
store,
|
|
309
|
+
PRINCIPAL,
|
|
310
|
+
REPO_ID,
|
|
311
|
+
buildRequest(body),
|
|
312
|
+
);
|
|
313
|
+
const text = await readBody(response);
|
|
314
|
+
const expected =
|
|
315
|
+
pktText("unpack ok\n") +
|
|
316
|
+
pktText("ng refs/heads/main path-violation: foo not under skill/\n") +
|
|
317
|
+
"0000";
|
|
318
|
+
expect(text).toBe(expected);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("authorize_denied translates byte-correct to forbidden", async () => {
|
|
322
|
+
const { store } = createStubStore(() => {
|
|
323
|
+
throw new Error("authorize_denied: refPattern mismatch");
|
|
324
|
+
});
|
|
325
|
+
const body = buildBody(
|
|
326
|
+
[
|
|
327
|
+
{
|
|
328
|
+
oldSha: ZERO_OID,
|
|
329
|
+
newSha: "a".repeat(40),
|
|
330
|
+
ref: "refs/heads/main",
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
PACK_BYTES,
|
|
334
|
+
"report-status",
|
|
335
|
+
);
|
|
336
|
+
const response = await handleReceivePack(
|
|
337
|
+
store,
|
|
338
|
+
PRINCIPAL,
|
|
339
|
+
REPO_ID,
|
|
340
|
+
buildRequest(body),
|
|
341
|
+
);
|
|
342
|
+
const text = await readBody(response);
|
|
343
|
+
const expected =
|
|
344
|
+
pktText("unpack ok\n") +
|
|
345
|
+
pktText("ng refs/heads/main forbidden\n") +
|
|
346
|
+
"0000";
|
|
347
|
+
expect(text).toBe(expected);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("sha_mismatch translates to ng <ref> sha-mismatch", async () => {
|
|
351
|
+
const { store } = createStubStore(() => {
|
|
352
|
+
throw new Error("sha_mismatch: pack tip != claimed newSha");
|
|
353
|
+
});
|
|
354
|
+
const body = buildBody(
|
|
355
|
+
[
|
|
356
|
+
{
|
|
357
|
+
oldSha: ZERO_OID,
|
|
358
|
+
newSha: "a".repeat(40),
|
|
359
|
+
ref: "refs/heads/main",
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
PACK_BYTES,
|
|
363
|
+
"report-status",
|
|
364
|
+
);
|
|
365
|
+
const response = await handleReceivePack(
|
|
366
|
+
store,
|
|
367
|
+
PRINCIPAL,
|
|
368
|
+
REPO_ID,
|
|
369
|
+
buildRequest(body),
|
|
370
|
+
);
|
|
371
|
+
const text = await readBody(response);
|
|
372
|
+
const expected =
|
|
373
|
+
pktText("unpack ok\n") +
|
|
374
|
+
pktText("ng refs/heads/main sha-mismatch\n") +
|
|
375
|
+
"0000";
|
|
376
|
+
expect(text).toBe(expected);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("handleReceivePack capability parsing", () => {
|
|
381
|
+
test("strips NUL-separated capabilities from the first command line", async () => {
|
|
382
|
+
const { store, calls } = createStubStore(() => undefined);
|
|
383
|
+
const newSha = "a".repeat(40);
|
|
384
|
+
const body = buildBody(
|
|
385
|
+
[{ oldSha: ZERO_OID, newSha, ref: "refs/heads/main" }],
|
|
386
|
+
PACK_BYTES,
|
|
387
|
+
"report-status side-band-64k agent=git/2.40.0",
|
|
388
|
+
);
|
|
389
|
+
await handleReceivePack(store, PRINCIPAL, REPO_ID, buildRequest(body));
|
|
390
|
+
expect(calls[0]?.ref).toBe("refs/heads/main");
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
function pktText(payload: string): string {
|
|
395
|
+
const enc = new TextEncoder().encode(payload);
|
|
396
|
+
return hex4(enc.length + 4) + payload;
|
|
397
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart-HTTP `git-receive-pack` request handler. Parses the client's
|
|
3
|
+
* ref-update commands followed by a packfile, drives the substrate's
|
|
4
|
+
* `receivePack` primitive for each command, and emits a
|
|
5
|
+
* `report-status` pkt-line stream as the response body.
|
|
6
|
+
*
|
|
7
|
+
* Request body shape (see `Documentation/technical/pack-protocol.txt`
|
|
8
|
+
* in the git source tree):
|
|
9
|
+
*
|
|
10
|
+
* <pkt><old-sha> <new-sha> <ref>\0<caps>\n</pkt> // first command
|
|
11
|
+
* <pkt><old-sha> <new-sha> <ref>\n</pkt> // subsequent commands
|
|
12
|
+
* ...
|
|
13
|
+
* 0000
|
|
14
|
+
* <raw packfile bytes>
|
|
15
|
+
*
|
|
16
|
+
* Response body shape (`report-status`):
|
|
17
|
+
*
|
|
18
|
+
* <pkt>unpack ok\n</pkt>
|
|
19
|
+
* <pkt>ok <ref>\n</pkt> // per-ref success
|
|
20
|
+
* <pkt>ng <ref> <reason>\n</pkt> // per-ref failure
|
|
21
|
+
* ...
|
|
22
|
+
* 0000
|
|
23
|
+
*
|
|
24
|
+
* Substrate error prefixes are translated to the report-status
|
|
25
|
+
* `ng <ref> <reason>` vocabulary. The substrate's `non_fast_forward:`
|
|
26
|
+
* prefix covers both stale-oldSha CAS failures and pure
|
|
27
|
+
* non-fast-forward rejections, both of which surface to the client as
|
|
28
|
+
* `non-fast-forward`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions";
|
|
32
|
+
|
|
33
|
+
import { writePktLine, writeFlush } from "./pkt-line";
|
|
34
|
+
|
|
35
|
+
const RECEIVE_PACK_CONTENT_TYPE = "application/x-git-receive-pack-result";
|
|
36
|
+
const ZERO_OID = "0".repeat(40);
|
|
37
|
+
|
|
38
|
+
type RefCommand = {
|
|
39
|
+
readonly oldSha: string;
|
|
40
|
+
readonly newSha: string;
|
|
41
|
+
readonly ref: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ParsedRequest = {
|
|
45
|
+
readonly commands: readonly RefCommand[];
|
|
46
|
+
readonly pack: Uint8Array;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseHex4(buf: Uint8Array, off: number): number {
|
|
50
|
+
let v = 0;
|
|
51
|
+
for (let i = 0; i < 4; i++) {
|
|
52
|
+
const c = buf[off + i];
|
|
53
|
+
if (c === undefined) {
|
|
54
|
+
throw new Error("truncated pkt-line: short header");
|
|
55
|
+
}
|
|
56
|
+
let d: number;
|
|
57
|
+
if (c >= 0x30 && c <= 0x39) {
|
|
58
|
+
d = c - 0x30;
|
|
59
|
+
} else if (c >= 0x61 && c <= 0x66) {
|
|
60
|
+
d = c - 0x61 + 10;
|
|
61
|
+
} else if (c >= 0x41 && c <= 0x46) {
|
|
62
|
+
d = c - 0x41 + 10;
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error("malformed pkt-line length");
|
|
65
|
+
}
|
|
66
|
+
v = (v << 4) | d;
|
|
67
|
+
}
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCommandLine(line: string): RefCommand {
|
|
72
|
+
// A command line is `<old-sha> <new-sha> <ref>`. The first command
|
|
73
|
+
// may carry capabilities after a NUL byte; the trailing `\n` is
|
|
74
|
+
// optional in some clients but is present in all stock git versions.
|
|
75
|
+
// Strip the trailer and the capabilities tail before parsing.
|
|
76
|
+
const nulIdx = line.indexOf("\0");
|
|
77
|
+
const head = nulIdx === -1 ? line : line.substring(0, nulIdx);
|
|
78
|
+
const trimmed = head.endsWith("\n") ? head.slice(0, -1) : head;
|
|
79
|
+
const parts = trimmed.split(" ");
|
|
80
|
+
if (parts.length < 3) {
|
|
81
|
+
throw new Error(`malformed receive-pack command: ${JSON.stringify(line)}`);
|
|
82
|
+
}
|
|
83
|
+
const oldSha = parts[0];
|
|
84
|
+
const newSha = parts[1];
|
|
85
|
+
// The ref may not contain a space, but split() guarantees it does
|
|
86
|
+
// not; everything after the second space is the ref.
|
|
87
|
+
const ref = parts.slice(2).join(" ");
|
|
88
|
+
if (oldSha === undefined || newSha === undefined || ref.length === 0) {
|
|
89
|
+
throw new Error(`malformed receive-pack command: ${JSON.stringify(line)}`);
|
|
90
|
+
}
|
|
91
|
+
return { oldSha, newSha, ref };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseRequestBody(body: Uint8Array): ParsedRequest {
|
|
95
|
+
const decoder = new TextDecoder();
|
|
96
|
+
const commands: RefCommand[] = [];
|
|
97
|
+
let off = 0;
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (off + 4 > body.length) {
|
|
100
|
+
throw new Error("truncated receive-pack request: incomplete header");
|
|
101
|
+
}
|
|
102
|
+
const length = parseHex4(body, off);
|
|
103
|
+
off += 4;
|
|
104
|
+
if (length === 0) {
|
|
105
|
+
// Flush packet marks end of command list; rest of body is the
|
|
106
|
+
// packfile (or empty when the client only deleted refs, which
|
|
107
|
+
// this hub does not support yet).
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (length === 1) {
|
|
111
|
+
throw new Error("unexpected delim pkt-line in receive-pack commands");
|
|
112
|
+
}
|
|
113
|
+
if (length < 4) {
|
|
114
|
+
throw new Error(`reserved pkt-line length: ${length}`);
|
|
115
|
+
}
|
|
116
|
+
const bodyLen = length - 4;
|
|
117
|
+
if (off + bodyLen > body.length) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"truncated receive-pack request: incomplete pkt-line body",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const line = decoder.decode(body.subarray(off, off + bodyLen));
|
|
123
|
+
off += bodyLen;
|
|
124
|
+
commands.push(parseCommandLine(line));
|
|
125
|
+
}
|
|
126
|
+
const pack = body.subarray(off);
|
|
127
|
+
return { commands, pack };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type RefStatus =
|
|
131
|
+
| { readonly kind: "ok"; readonly ref: string }
|
|
132
|
+
| { readonly kind: "ng"; readonly ref: string; readonly reason: string };
|
|
133
|
+
|
|
134
|
+
const ERROR_PREFIXES = [
|
|
135
|
+
"non_fast_forward:",
|
|
136
|
+
"path_violation:",
|
|
137
|
+
"authorize_denied:",
|
|
138
|
+
"sha_mismatch:",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
type SubstrateErrorPrefix = (typeof ERROR_PREFIXES)[number];
|
|
142
|
+
|
|
143
|
+
function classifyError(err: unknown): {
|
|
144
|
+
prefix: SubstrateErrorPrefix;
|
|
145
|
+
detail: string;
|
|
146
|
+
} | null {
|
|
147
|
+
if (!(err instanceof Error)) return null;
|
|
148
|
+
for (const prefix of ERROR_PREFIXES) {
|
|
149
|
+
if (err.message.startsWith(prefix)) {
|
|
150
|
+
const detail = err.message.substring(prefix.length).trimStart();
|
|
151
|
+
return { prefix, detail };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function translateError(err: unknown, ref: string): RefStatus {
|
|
158
|
+
const classified = classifyError(err);
|
|
159
|
+
if (classified === null) {
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
switch (classified.prefix) {
|
|
163
|
+
case "non_fast_forward:":
|
|
164
|
+
return { kind: "ng", ref, reason: "non-fast-forward" };
|
|
165
|
+
case "path_violation:":
|
|
166
|
+
return {
|
|
167
|
+
kind: "ng",
|
|
168
|
+
ref,
|
|
169
|
+
reason: `path-violation: ${classified.detail}`,
|
|
170
|
+
};
|
|
171
|
+
case "authorize_denied:":
|
|
172
|
+
return { kind: "ng", ref, reason: "forbidden" };
|
|
173
|
+
case "sha_mismatch:":
|
|
174
|
+
return { kind: "ng", ref, reason: "sha-mismatch" };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runCommand(
|
|
179
|
+
repoStore: RepoStore,
|
|
180
|
+
principal: Principal,
|
|
181
|
+
repoId: RepoId,
|
|
182
|
+
command: RefCommand,
|
|
183
|
+
pack: Uint8Array,
|
|
184
|
+
): Promise<RefStatus> {
|
|
185
|
+
const expectedOldSha = command.oldSha === ZERO_OID ? null : command.oldSha;
|
|
186
|
+
try {
|
|
187
|
+
await repoStore.receivePack(
|
|
188
|
+
principal,
|
|
189
|
+
repoId,
|
|
190
|
+
command.ref,
|
|
191
|
+
pack,
|
|
192
|
+
command.newSha,
|
|
193
|
+
expectedOldSha,
|
|
194
|
+
);
|
|
195
|
+
return { kind: "ok", ref: command.ref };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return translateError(err, command.ref);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function writeReport(
|
|
202
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
203
|
+
unpackStatus: string,
|
|
204
|
+
statuses: readonly RefStatus[],
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
await writePktLine(writer, `unpack ${unpackStatus}\n`);
|
|
207
|
+
for (const status of statuses) {
|
|
208
|
+
if (status.kind === "ok") {
|
|
209
|
+
await writePktLine(writer, `ok ${status.ref}\n`);
|
|
210
|
+
} else {
|
|
211
|
+
await writePktLine(writer, `ng ${status.ref} ${status.reason}\n`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await writeFlush(writer);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function handleReceivePack(
|
|
218
|
+
repoStore: RepoStore,
|
|
219
|
+
principal: Principal,
|
|
220
|
+
repoId: RepoId,
|
|
221
|
+
request: Request,
|
|
222
|
+
): Promise<Response> {
|
|
223
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
224
|
+
const parsed = parseRequestBody(body);
|
|
225
|
+
|
|
226
|
+
const statuses: RefStatus[] = [];
|
|
227
|
+
for (const command of parsed.commands) {
|
|
228
|
+
const status = await runCommand(
|
|
229
|
+
repoStore,
|
|
230
|
+
principal,
|
|
231
|
+
repoId,
|
|
232
|
+
command,
|
|
233
|
+
parsed.pack,
|
|
234
|
+
);
|
|
235
|
+
statuses.push(status);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
239
|
+
async start(controller) {
|
|
240
|
+
const sink = new WritableStream<Uint8Array>({
|
|
241
|
+
write(chunk) {
|
|
242
|
+
controller.enqueue(chunk);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const writer = sink.getWriter();
|
|
246
|
+
try {
|
|
247
|
+
await writeReport(writer, "ok", statuses);
|
|
248
|
+
await writer.close();
|
|
249
|
+
controller.close();
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
await writer.abort(cause).catch(() => undefined);
|
|
252
|
+
controller.error(cause);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return new Response(stream, {
|
|
258
|
+
status: 200,
|
|
259
|
+
headers: { "content-type": RECEIVE_PACK_CONTENT_TYPE },
|
|
260
|
+
});
|
|
261
|
+
}
|