@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,545 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import git from "isomorphic-git";
|
|
6
|
+
import type { RepoId } from "@intx/types/sidecar";
|
|
7
|
+
import {
|
|
8
|
+
handleUploadPack,
|
|
9
|
+
UPLOAD_PACK_RESULT_CONTENT_TYPE,
|
|
10
|
+
type UploadPackPrincipal,
|
|
11
|
+
type UploadPackRepoStore,
|
|
12
|
+
} from "./upload-pack";
|
|
13
|
+
import type { RefEntry } from "./advertise-refs";
|
|
14
|
+
|
|
15
|
+
const REPO_ID: RepoId = { kind: "agent-state", id: "test" };
|
|
16
|
+
|
|
17
|
+
const tempDirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
async function tempDir(): Promise<string> {
|
|
20
|
+
const d = await fs.promises.mkdtemp(
|
|
21
|
+
path.join(os.tmpdir(), "interchange-upload-pack-test-"),
|
|
22
|
+
);
|
|
23
|
+
tempDirs.push(d);
|
|
24
|
+
return d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
const dirs = tempDirs.splice(0);
|
|
29
|
+
await Promise.all(
|
|
30
|
+
dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const AUTHOR = { name: "Test", email: "test@interchange.dev" };
|
|
35
|
+
|
|
36
|
+
async function writeAndCommit(
|
|
37
|
+
dir: string,
|
|
38
|
+
files: { filepath: string; content: string }[],
|
|
39
|
+
message: string,
|
|
40
|
+
parent?: string[],
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
for (const { filepath, content } of files) {
|
|
43
|
+
const full = path.join(dir, filepath);
|
|
44
|
+
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
|
45
|
+
await fs.promises.writeFile(full, content);
|
|
46
|
+
await git.add({ fs, dir, filepath });
|
|
47
|
+
}
|
|
48
|
+
return git.commit({
|
|
49
|
+
fs,
|
|
50
|
+
dir,
|
|
51
|
+
message,
|
|
52
|
+
author: AUTHOR,
|
|
53
|
+
...(parent ? { parent } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function makeLinearRepo(): Promise<{
|
|
58
|
+
dir: string;
|
|
59
|
+
c1: string;
|
|
60
|
+
c2: string;
|
|
61
|
+
c3: string;
|
|
62
|
+
}> {
|
|
63
|
+
const dir = await tempDir();
|
|
64
|
+
await git.init({ fs, dir, defaultBranch: "main" });
|
|
65
|
+
const c1 = await writeAndCommit(
|
|
66
|
+
dir,
|
|
67
|
+
[{ filepath: "a.txt", content: "v1" }],
|
|
68
|
+
"c1",
|
|
69
|
+
);
|
|
70
|
+
const c2 = await writeAndCommit(
|
|
71
|
+
dir,
|
|
72
|
+
[{ filepath: "a.txt", content: "v2" }],
|
|
73
|
+
"c2",
|
|
74
|
+
);
|
|
75
|
+
const c3 = await writeAndCommit(
|
|
76
|
+
dir,
|
|
77
|
+
[{ filepath: "a.txt", content: "v3" }],
|
|
78
|
+
"c3",
|
|
79
|
+
);
|
|
80
|
+
await git.writeRef({
|
|
81
|
+
fs,
|
|
82
|
+
dir,
|
|
83
|
+
ref: "refs/heads/main",
|
|
84
|
+
value: c3,
|
|
85
|
+
force: true,
|
|
86
|
+
});
|
|
87
|
+
return { dir, c1, c2, c3 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function makeTwoBranchRepo(): Promise<{
|
|
91
|
+
dir: string;
|
|
92
|
+
base: string;
|
|
93
|
+
branchA: string;
|
|
94
|
+
branchB: string;
|
|
95
|
+
}> {
|
|
96
|
+
const dir = await tempDir();
|
|
97
|
+
await git.init({ fs, dir, defaultBranch: "main" });
|
|
98
|
+
const base = await writeAndCommit(
|
|
99
|
+
dir,
|
|
100
|
+
[{ filepath: "base.txt", content: "base" }],
|
|
101
|
+
"base",
|
|
102
|
+
);
|
|
103
|
+
const branchA = await writeAndCommit(
|
|
104
|
+
dir,
|
|
105
|
+
[{ filepath: "a.txt", content: "alpha" }],
|
|
106
|
+
"branch A",
|
|
107
|
+
);
|
|
108
|
+
await git.writeRef({
|
|
109
|
+
fs,
|
|
110
|
+
dir,
|
|
111
|
+
ref: "refs/heads/main",
|
|
112
|
+
value: base,
|
|
113
|
+
force: true,
|
|
114
|
+
});
|
|
115
|
+
await fs.promises.rm(path.join(dir, "a.txt"), { force: true });
|
|
116
|
+
await git.remove({ fs, dir, filepath: "a.txt" }).catch(() => undefined);
|
|
117
|
+
const branchB = await writeAndCommit(
|
|
118
|
+
dir,
|
|
119
|
+
[{ filepath: "b.txt", content: "bravo" }],
|
|
120
|
+
"branch B",
|
|
121
|
+
);
|
|
122
|
+
await git.writeRef({
|
|
123
|
+
fs,
|
|
124
|
+
dir,
|
|
125
|
+
ref: "refs/heads/branch-a",
|
|
126
|
+
value: branchA,
|
|
127
|
+
force: true,
|
|
128
|
+
});
|
|
129
|
+
await git.writeRef({
|
|
130
|
+
fs,
|
|
131
|
+
dir,
|
|
132
|
+
ref: "refs/heads/branch-b",
|
|
133
|
+
value: branchB,
|
|
134
|
+
force: true,
|
|
135
|
+
});
|
|
136
|
+
return { dir, base, branchA, branchB };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function principalWith(refPattern: string): UploadPackPrincipal {
|
|
140
|
+
return { kind: "user", tokenClaims: { refPattern } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function repoStoreFor(dir: string, refs: RefEntry[]): UploadPackRepoStore {
|
|
144
|
+
return {
|
|
145
|
+
listRefs: async () => refs.slice(),
|
|
146
|
+
getRepoDir: async () => dir,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hex4(n: number): string {
|
|
151
|
+
return n.toString(16).padStart(4, "0");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pkt(payload: string): Uint8Array {
|
|
155
|
+
const body = new TextEncoder().encode(payload);
|
|
156
|
+
const header = new TextEncoder().encode(hex4(body.length + 4));
|
|
157
|
+
const out = new Uint8Array(header.length + body.length);
|
|
158
|
+
out.set(header, 0);
|
|
159
|
+
out.set(body, header.length);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function flush(): Uint8Array {
|
|
164
|
+
return new TextEncoder().encode("0000");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
168
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
169
|
+
const out = new Uint8Array(total);
|
|
170
|
+
let off = 0;
|
|
171
|
+
for (const p of parts) {
|
|
172
|
+
out.set(p, off);
|
|
173
|
+
off += p.length;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function uploadPackRequest(body: Uint8Array): Request {
|
|
179
|
+
return new Request("https://hub.example/upload-pack", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/x-git-upload-pack-request" },
|
|
182
|
+
body,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readAll(response: Response): Promise<Uint8Array> {
|
|
187
|
+
if (!response.body) {
|
|
188
|
+
throw new Error("response has no body");
|
|
189
|
+
}
|
|
190
|
+
const reader = response.body.getReader();
|
|
191
|
+
const parts: Uint8Array[] = [];
|
|
192
|
+
for (;;) {
|
|
193
|
+
const r = await reader.read();
|
|
194
|
+
if (r.done) break;
|
|
195
|
+
if (r.value) parts.push(r.value);
|
|
196
|
+
}
|
|
197
|
+
return concat(...parts);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type ParsedPkt = { kind: "flush" } | { kind: "data"; payload: Uint8Array };
|
|
201
|
+
|
|
202
|
+
function parsePktStream(buf: Uint8Array): ParsedPkt[] {
|
|
203
|
+
const out: ParsedPkt[] = [];
|
|
204
|
+
let off = 0;
|
|
205
|
+
const dec = new TextDecoder();
|
|
206
|
+
while (off < buf.length) {
|
|
207
|
+
if (off + 4 > buf.length) {
|
|
208
|
+
throw new Error(`truncated pkt-line at ${off.toString()}`);
|
|
209
|
+
}
|
|
210
|
+
const lenHex = dec.decode(buf.slice(off, off + 4));
|
|
211
|
+
const len = parseInt(lenHex, 16);
|
|
212
|
+
if (Number.isNaN(len)) {
|
|
213
|
+
throw new Error(`bad pkt-line length: ${lenHex}`);
|
|
214
|
+
}
|
|
215
|
+
off += 4;
|
|
216
|
+
if (len === 0) {
|
|
217
|
+
out.push({ kind: "flush" });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (len < 4) {
|
|
221
|
+
throw new Error(`reserved pkt-line length: ${len.toString()}`);
|
|
222
|
+
}
|
|
223
|
+
const bodyLen = len - 4;
|
|
224
|
+
if (off + bodyLen > buf.length) {
|
|
225
|
+
throw new Error(`truncated pkt-line body at ${off.toString()}`);
|
|
226
|
+
}
|
|
227
|
+
out.push({ kind: "data", payload: buf.slice(off, off + bodyLen) });
|
|
228
|
+
off += bodyLen;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type Frame = { channel: number; payload: Uint8Array };
|
|
234
|
+
|
|
235
|
+
function splitNakAndFrames(buf: Uint8Array): {
|
|
236
|
+
nak: Uint8Array;
|
|
237
|
+
frames: Frame[];
|
|
238
|
+
} {
|
|
239
|
+
const pkts = parsePktStream(buf);
|
|
240
|
+
if (pkts.length === 0) {
|
|
241
|
+
throw new Error("expected at least one pkt-line");
|
|
242
|
+
}
|
|
243
|
+
const first = pkts[0];
|
|
244
|
+
if (!first || first.kind !== "data") {
|
|
245
|
+
throw new Error("expected NAK pkt-line first");
|
|
246
|
+
}
|
|
247
|
+
const nak = first.payload;
|
|
248
|
+
const frames: Frame[] = [];
|
|
249
|
+
for (let i = 1; i < pkts.length; i++) {
|
|
250
|
+
const p = pkts[i];
|
|
251
|
+
if (!p) continue;
|
|
252
|
+
if (p.kind === "flush") continue;
|
|
253
|
+
const channel = p.payload[0];
|
|
254
|
+
if (channel === undefined) {
|
|
255
|
+
throw new Error("side-band frame missing channel byte");
|
|
256
|
+
}
|
|
257
|
+
frames.push({ channel, payload: p.payload.slice(1) });
|
|
258
|
+
}
|
|
259
|
+
return { nak, frames };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function assembleChannel1(frames: Frame[]): Uint8Array {
|
|
263
|
+
const ch1: Uint8Array[] = [];
|
|
264
|
+
for (const f of frames) {
|
|
265
|
+
if (f.channel === 1) ch1.push(f.payload);
|
|
266
|
+
}
|
|
267
|
+
return concat(...ch1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
describe("handleUploadPack request parsing", () => {
|
|
271
|
+
test("parses canonical want/have/done into a NAK + pack response", async () => {
|
|
272
|
+
const { dir, c1, c3 } = await makeLinearRepo();
|
|
273
|
+
const body = concat(
|
|
274
|
+
pkt(`want ${c3}\n`),
|
|
275
|
+
flush(),
|
|
276
|
+
pkt(`have ${c1}\n`),
|
|
277
|
+
pkt("done\n"),
|
|
278
|
+
);
|
|
279
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
280
|
+
const response = await handleUploadPack(
|
|
281
|
+
store,
|
|
282
|
+
principalWith("**"),
|
|
283
|
+
REPO_ID,
|
|
284
|
+
uploadPackRequest(body),
|
|
285
|
+
);
|
|
286
|
+
expect(response.status).toBe(200);
|
|
287
|
+
expect(response.headers.get("content-type")).toBe(
|
|
288
|
+
UPLOAD_PACK_RESULT_CONTENT_TYPE,
|
|
289
|
+
);
|
|
290
|
+
const buf = await readAll(response);
|
|
291
|
+
const { nak, frames } = splitNakAndFrames(buf);
|
|
292
|
+
expect(new TextDecoder().decode(nak)).toBe("NAK\n");
|
|
293
|
+
expect(frames.length).toBeGreaterThan(0);
|
|
294
|
+
const channels = new Set(frames.map((f) => f.channel));
|
|
295
|
+
expect(channels.has(1)).toBe(true);
|
|
296
|
+
expect(channels.has(2)).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("handleUploadPack pack contents", () => {
|
|
301
|
+
test("single want / no haves yields a full pack", async () => {
|
|
302
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
303
|
+
const body = concat(pkt(`want ${c3}\n`), flush(), pkt("done\n"));
|
|
304
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
305
|
+
const response = await handleUploadPack(
|
|
306
|
+
store,
|
|
307
|
+
principalWith("**"),
|
|
308
|
+
REPO_ID,
|
|
309
|
+
uploadPackRequest(body),
|
|
310
|
+
);
|
|
311
|
+
const buf = await readAll(response);
|
|
312
|
+
const { frames } = splitNakAndFrames(buf);
|
|
313
|
+
const pack = assembleChannel1(frames);
|
|
314
|
+
expect(pack.length).toBeGreaterThan(0);
|
|
315
|
+
expect(new TextDecoder().decode(pack.slice(0, 4))).toBe("PACK");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("single want / overlapping have yields a smaller delta pack", async () => {
|
|
319
|
+
const { dir, c1, c3 } = await makeLinearRepo();
|
|
320
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
321
|
+
|
|
322
|
+
const fullResponse = await handleUploadPack(
|
|
323
|
+
store,
|
|
324
|
+
principalWith("**"),
|
|
325
|
+
REPO_ID,
|
|
326
|
+
uploadPackRequest(concat(pkt(`want ${c3}\n`), flush(), pkt("done\n"))),
|
|
327
|
+
);
|
|
328
|
+
const full = await readAll(fullResponse);
|
|
329
|
+
const deltaResponse = await handleUploadPack(
|
|
330
|
+
store,
|
|
331
|
+
principalWith("**"),
|
|
332
|
+
REPO_ID,
|
|
333
|
+
uploadPackRequest(
|
|
334
|
+
concat(
|
|
335
|
+
pkt(`want ${c3}\n`),
|
|
336
|
+
flush(),
|
|
337
|
+
pkt(`have ${c1}\n`),
|
|
338
|
+
pkt("done\n"),
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
const delta = await readAll(deltaResponse);
|
|
343
|
+
|
|
344
|
+
const fullPack = assembleChannel1(splitNakAndFrames(full).frames);
|
|
345
|
+
const deltaPack = assembleChannel1(splitNakAndFrames(delta).frames);
|
|
346
|
+
expect(deltaPack.length).toBeLessThan(fullPack.length);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("multi-want union pack covers both branch tips", async () => {
|
|
350
|
+
const { dir, branchA, branchB } = await makeTwoBranchRepo();
|
|
351
|
+
const store = repoStoreFor(dir, [
|
|
352
|
+
{ name: "refs/heads/branch-a", sha: branchA },
|
|
353
|
+
{ name: "refs/heads/branch-b", sha: branchB },
|
|
354
|
+
]);
|
|
355
|
+
const body = concat(
|
|
356
|
+
pkt(`want ${branchA}\n`),
|
|
357
|
+
pkt(`want ${branchB}\n`),
|
|
358
|
+
flush(),
|
|
359
|
+
pkt("done\n"),
|
|
360
|
+
);
|
|
361
|
+
const response = await handleUploadPack(
|
|
362
|
+
store,
|
|
363
|
+
principalWith("**"),
|
|
364
|
+
REPO_ID,
|
|
365
|
+
uploadPackRequest(body),
|
|
366
|
+
);
|
|
367
|
+
const buf = await readAll(response);
|
|
368
|
+
const { frames } = splitNakAndFrames(buf);
|
|
369
|
+
const pack = assembleChannel1(frames);
|
|
370
|
+
expect(new TextDecoder().decode(pack.slice(0, 4))).toBe("PACK");
|
|
371
|
+
expect(pack.length).toBeGreaterThan(0);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("handleUploadPack refPattern filter", () => {
|
|
376
|
+
test("want of a SHA only reachable from a forbidden ref produces ERR forbidden ref", async () => {
|
|
377
|
+
const { dir, branchA, branchB } = await makeTwoBranchRepo();
|
|
378
|
+
const store = repoStoreFor(dir, [
|
|
379
|
+
{ name: "refs/heads/branch-a", sha: branchA },
|
|
380
|
+
{ name: "refs/heads/branch-b", sha: branchB },
|
|
381
|
+
]);
|
|
382
|
+
const body = concat(pkt(`want ${branchB}\n`), flush(), pkt("done\n"));
|
|
383
|
+
const response = await handleUploadPack(
|
|
384
|
+
store,
|
|
385
|
+
principalWith("refs/heads/branch-a"),
|
|
386
|
+
REPO_ID,
|
|
387
|
+
uploadPackRequest(body),
|
|
388
|
+
);
|
|
389
|
+
expect(response.status).toBe(200);
|
|
390
|
+
const buf = await readAll(response);
|
|
391
|
+
const pkts = parsePktStream(buf);
|
|
392
|
+
const data = pkts.filter(
|
|
393
|
+
(p): p is { kind: "data"; payload: Uint8Array } => p.kind === "data",
|
|
394
|
+
);
|
|
395
|
+
expect(data.length).toBe(1);
|
|
396
|
+
const first = data[0];
|
|
397
|
+
if (!first) throw new Error("expected one data pkt-line");
|
|
398
|
+
expect(new TextDecoder().decode(first.payload)).toBe("ERR forbidden ref\n");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("want of an unknown SHA produces ERR upload-pack: not our ref", async () => {
|
|
402
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
403
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
404
|
+
const bogus = "0".repeat(40);
|
|
405
|
+
const body = concat(pkt(`want ${bogus}\n`), flush(), pkt("done\n"));
|
|
406
|
+
const response = await handleUploadPack(
|
|
407
|
+
store,
|
|
408
|
+
principalWith("**"),
|
|
409
|
+
REPO_ID,
|
|
410
|
+
uploadPackRequest(body),
|
|
411
|
+
);
|
|
412
|
+
expect(response.status).toBe(200);
|
|
413
|
+
const buf = await readAll(response);
|
|
414
|
+
const pkts = parsePktStream(buf);
|
|
415
|
+
const data = pkts.filter(
|
|
416
|
+
(p): p is { kind: "data"; payload: Uint8Array } => p.kind === "data",
|
|
417
|
+
);
|
|
418
|
+
expect(data.length).toBe(1);
|
|
419
|
+
const first = data[0];
|
|
420
|
+
if (!first) throw new Error("expected one data pkt-line");
|
|
421
|
+
expect(new TextDecoder().decode(first.payload)).toBe(
|
|
422
|
+
"ERR upload-pack: not our ref\n",
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("want of a non-commit OID reachable from an allowed ref produces ERR upload-pack: not our ref", async () => {
|
|
427
|
+
// A hand-crafted client could want a blob or tree OID that
|
|
428
|
+
// appears in some allowed ref's tree. Such an OID passes the
|
|
429
|
+
// bare `allowedObjects.has(want)` membership check but is not a
|
|
430
|
+
// commit, so it cannot be a valid `want` per the smart-HTTP
|
|
431
|
+
// protocol. The classifier must reject it explicitly rather than
|
|
432
|
+
// letting it fall through to an empty NAK response.
|
|
433
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
434
|
+
const commit = await git.readCommit({ fs, dir, oid: c3 });
|
|
435
|
+
const treeOid = commit.commit.tree;
|
|
436
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
437
|
+
const body = concat(pkt(`want ${treeOid}\n`), flush(), pkt("done\n"));
|
|
438
|
+
const response = await handleUploadPack(
|
|
439
|
+
store,
|
|
440
|
+
principalWith("**"),
|
|
441
|
+
REPO_ID,
|
|
442
|
+
uploadPackRequest(body),
|
|
443
|
+
);
|
|
444
|
+
expect(response.status).toBe(200);
|
|
445
|
+
const buf = await readAll(response);
|
|
446
|
+
const pkts = parsePktStream(buf);
|
|
447
|
+
const data = pkts.filter(
|
|
448
|
+
(p): p is { kind: "data"; payload: Uint8Array } => p.kind === "data",
|
|
449
|
+
);
|
|
450
|
+
expect(data.length).toBe(1);
|
|
451
|
+
const first = data[0];
|
|
452
|
+
if (!first) throw new Error("expected one data pkt-line");
|
|
453
|
+
expect(new TextDecoder().decode(first.payload)).toBe(
|
|
454
|
+
"ERR upload-pack: not our ref\n",
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("want of an allowed ref tip succeeds when refPattern includes it", async () => {
|
|
459
|
+
const { dir, branchA, branchB } = await makeTwoBranchRepo();
|
|
460
|
+
const store = repoStoreFor(dir, [
|
|
461
|
+
{ name: "refs/heads/branch-a", sha: branchA },
|
|
462
|
+
{ name: "refs/heads/branch-b", sha: branchB },
|
|
463
|
+
]);
|
|
464
|
+
const body = concat(pkt(`want ${branchA}\n`), flush(), pkt("done\n"));
|
|
465
|
+
const response = await handleUploadPack(
|
|
466
|
+
store,
|
|
467
|
+
principalWith("refs/heads/branch-a"),
|
|
468
|
+
REPO_ID,
|
|
469
|
+
uploadPackRequest(body),
|
|
470
|
+
);
|
|
471
|
+
expect(response.status).toBe(200);
|
|
472
|
+
const buf = await readAll(response);
|
|
473
|
+
const { nak, frames } = splitNakAndFrames(buf);
|
|
474
|
+
expect(new TextDecoder().decode(nak)).toBe("NAK\n");
|
|
475
|
+
const pack = assembleChannel1(frames);
|
|
476
|
+
expect(new TextDecoder().decode(pack.slice(0, 4))).toBe("PACK");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("handleUploadPack substrate error translation", () => {
|
|
481
|
+
test("authorize_denied thrown by listRefs surfaces as ERR forbidden ref", async () => {
|
|
482
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
483
|
+
const store: UploadPackRepoStore = {
|
|
484
|
+
listRefs: async () => {
|
|
485
|
+
throw new Error("authorize_denied: token expired");
|
|
486
|
+
},
|
|
487
|
+
getRepoDir: async () => dir,
|
|
488
|
+
};
|
|
489
|
+
const body = concat(pkt(`want ${c3}\n`), flush(), pkt("done\n"));
|
|
490
|
+
const response = await handleUploadPack(
|
|
491
|
+
store,
|
|
492
|
+
principalWith("**"),
|
|
493
|
+
REPO_ID,
|
|
494
|
+
uploadPackRequest(body),
|
|
495
|
+
);
|
|
496
|
+
expect(response.status).toBe(200);
|
|
497
|
+
const buf = await readAll(response);
|
|
498
|
+
const pkts = parsePktStream(buf);
|
|
499
|
+
const data = pkts.filter(
|
|
500
|
+
(p): p is { kind: "data"; payload: Uint8Array } => p.kind === "data",
|
|
501
|
+
);
|
|
502
|
+
expect(data.length).toBe(1);
|
|
503
|
+
const first = data[0];
|
|
504
|
+
if (!first) throw new Error("expected one data pkt-line");
|
|
505
|
+
expect(new TextDecoder().decode(first.payload)).toBe("ERR forbidden ref\n");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("non-substrate errors propagate so the HTTP layer sees a 500", async () => {
|
|
509
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
510
|
+
const store: UploadPackRepoStore = {
|
|
511
|
+
listRefs: async () => {
|
|
512
|
+
throw new Error("disk on fire");
|
|
513
|
+
},
|
|
514
|
+
getRepoDir: async () => dir,
|
|
515
|
+
};
|
|
516
|
+
const body = concat(pkt(`want ${c3}\n`), flush(), pkt("done\n"));
|
|
517
|
+
await expect(
|
|
518
|
+
handleUploadPack(
|
|
519
|
+
store,
|
|
520
|
+
principalWith("**"),
|
|
521
|
+
REPO_ID,
|
|
522
|
+
uploadPackRequest(body),
|
|
523
|
+
),
|
|
524
|
+
).rejects.toThrow("disk on fire");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("handleUploadPack channel routing", () => {
|
|
529
|
+
test("emits no progress frames on channel 2 by default", async () => {
|
|
530
|
+
const { dir, c3 } = await makeLinearRepo();
|
|
531
|
+
const store = repoStoreFor(dir, [{ name: "refs/heads/main", sha: c3 }]);
|
|
532
|
+
const body = concat(pkt(`want ${c3}\n`), flush(), pkt("done\n"));
|
|
533
|
+
const response = await handleUploadPack(
|
|
534
|
+
store,
|
|
535
|
+
principalWith("**"),
|
|
536
|
+
REPO_ID,
|
|
537
|
+
uploadPackRequest(body),
|
|
538
|
+
);
|
|
539
|
+
const buf = await readAll(response);
|
|
540
|
+
const { frames } = splitNakAndFrames(buf);
|
|
541
|
+
for (const f of frames) {
|
|
542
|
+
expect(f.channel).toBe(1);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
});
|