@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart-HTTP `info/refs` advertisement for upload-pack and
|
|
3
|
+
* receive-pack. Produces a pkt-line stream of the shape:
|
|
4
|
+
*
|
|
5
|
+
* <pkt># service=git-upload-pack\n</pkt>
|
|
6
|
+
* 0000
|
|
7
|
+
* <pkt><sha> HEAD\0<caps with symref=HEAD:<target>>\n</pkt>
|
|
8
|
+
* <pkt><sha> <ref>\n</pkt>
|
|
9
|
+
* ...
|
|
10
|
+
* 0000
|
|
11
|
+
*
|
|
12
|
+
* The advertisement begins with HEAD when the repo has a born HEAD;
|
|
13
|
+
* HEAD carries the capability list NUL-separated and a
|
|
14
|
+
* `symref=HEAD:<target>` token so stock `git clone` lands on a real
|
|
15
|
+
* branch instead of leaving the working tree unborn. The visible refs
|
|
16
|
+
* follow in lexicographic order.
|
|
17
|
+
*
|
|
18
|
+
* Refs are filtered against `principal.tokenClaims.refPattern` via
|
|
19
|
+
* the shared simple-glob matcher before advertisement so a token
|
|
20
|
+
* cannot learn about refs outside its declared scope. HEAD is itself a
|
|
21
|
+
* symbolic alias and not subject to refPattern filtering, but it is
|
|
22
|
+
* only advertised when its target ref survives that filter — a token
|
|
23
|
+
* that cannot see the target cannot learn about HEAD either.
|
|
24
|
+
*
|
|
25
|
+
* When no refs survive filtering (or the repo is empty) the
|
|
26
|
+
* advertisement emits a single zero-oid `capabilities^{}` record so
|
|
27
|
+
* that stock `git clone` and `git ls-remote` accept the response.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { RepoId } from "@intx/types/sidecar";
|
|
31
|
+
import { glob } from "@intx/hub-common";
|
|
32
|
+
|
|
33
|
+
import { writePktLine, writeFlush } from "./pkt-line";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Principal contract consumed by the advertise layer. Kept narrow on
|
|
37
|
+
* purpose: the advertiser only needs the ref-scope claim, so any
|
|
38
|
+
* principal shape carrying `tokenClaims.refPattern` satisfies it.
|
|
39
|
+
*/
|
|
40
|
+
export type AdvertisePrincipal = {
|
|
41
|
+
readonly kind: string;
|
|
42
|
+
readonly tokenClaims: {
|
|
43
|
+
readonly refPattern: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RefEntry = {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly sha: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Result of resolving HEAD on the underlying repo. `symbolicTarget` is
|
|
54
|
+
* the ref HEAD symbolically points at (e.g. `refs/heads/main`); `sha`
|
|
55
|
+
* is the SHA that target currently resolves to. Both fields are
|
|
56
|
+
* required: a detached HEAD or an unborn HEAD is signalled by
|
|
57
|
+
* returning `null` from `resolveHead`, not by populating one field and
|
|
58
|
+
* leaving the other empty.
|
|
59
|
+
*/
|
|
60
|
+
export type HeadResolution = {
|
|
61
|
+
readonly symbolicTarget: string;
|
|
62
|
+
readonly sha: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ref-listing capability the advertiser depends on. The substrate or
|
|
67
|
+
* a repo-direct adapter implements this; the advertiser does not care
|
|
68
|
+
* which.
|
|
69
|
+
*/
|
|
70
|
+
export interface RefSource {
|
|
71
|
+
listRefs(principal: AdvertisePrincipal, repoId: RepoId): Promise<RefEntry[]>;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve HEAD into the ref it symbolically targets plus the SHA
|
|
74
|
+
* that ref currently resolves to. Returns `null` when HEAD is
|
|
75
|
+
* unborn (no commits yet), detached, or the on-disk repo does not
|
|
76
|
+
* exist. The advertiser uses the result to emit the
|
|
77
|
+
* `symref=HEAD:<target>` capability so stock `git clone` checks out
|
|
78
|
+
* a real branch.
|
|
79
|
+
*/
|
|
80
|
+
resolveHead(
|
|
81
|
+
principal: AdvertisePrincipal,
|
|
82
|
+
repoId: RepoId,
|
|
83
|
+
): Promise<HeadResolution | null>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const INTERCHANGE_HUB_AGENT = "interchange-hub/0.0.0";
|
|
87
|
+
|
|
88
|
+
const BASELINE_CAPABILITIES = [
|
|
89
|
+
"ofs-delta",
|
|
90
|
+
"object-format=sha1",
|
|
91
|
+
`agent=${INTERCHANGE_HUB_AGENT}`,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export const UPLOAD_PACK_CAPABILITIES = [
|
|
95
|
+
"side-band-64k",
|
|
96
|
+
...BASELINE_CAPABILITIES,
|
|
97
|
+
].join(" ");
|
|
98
|
+
|
|
99
|
+
// receive-pack does not advertise `side-band-64k`: the handler returns
|
|
100
|
+
// the `report-status` payload as raw pkt-lines, not channel-wrapped.
|
|
101
|
+
// Advertising side-band-64k would invite the client to expect a
|
|
102
|
+
// channel-framed response, which `handleReceivePack` does not emit.
|
|
103
|
+
export const RECEIVE_PACK_CAPABILITIES = [
|
|
104
|
+
...BASELINE_CAPABILITIES,
|
|
105
|
+
"report-status",
|
|
106
|
+
].join(" ");
|
|
107
|
+
|
|
108
|
+
export const EMPTY_REPO_OID = "0".repeat(40);
|
|
109
|
+
|
|
110
|
+
type Service = "git-upload-pack" | "git-receive-pack";
|
|
111
|
+
|
|
112
|
+
function filterAndSort(
|
|
113
|
+
refs: readonly RefEntry[],
|
|
114
|
+
refPattern: string,
|
|
115
|
+
): RefEntry[] {
|
|
116
|
+
const allowed = refs.filter((r) => glob.match(refPattern, r.name));
|
|
117
|
+
allowed.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
118
|
+
return allowed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeAdvertisement(
|
|
122
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
123
|
+
service: Service,
|
|
124
|
+
capabilities: string,
|
|
125
|
+
refs: readonly RefEntry[],
|
|
126
|
+
head: HeadResolution | null,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
await writePktLine(writer, `# service=${service}\n`);
|
|
129
|
+
await writeFlush(writer);
|
|
130
|
+
|
|
131
|
+
// HEAD is only advertised when its symbolic target survives ref
|
|
132
|
+
// filtering: a token that cannot see the target ref has no business
|
|
133
|
+
// learning about HEAD either, and stock git would otherwise resolve
|
|
134
|
+
// the symref against a ref it never received and abort.
|
|
135
|
+
const headTargetVisible =
|
|
136
|
+
head !== null &&
|
|
137
|
+
refs.some((r) => r.name === head.symbolicTarget && r.sha === head.sha);
|
|
138
|
+
|
|
139
|
+
if (refs.length === 0) {
|
|
140
|
+
await writePktLine(
|
|
141
|
+
writer,
|
|
142
|
+
`${EMPTY_REPO_OID} capabilities^{}\0${capabilities}\n`,
|
|
143
|
+
);
|
|
144
|
+
} else if (head !== null && headTargetVisible) {
|
|
145
|
+
const capsWithSymref = `${capabilities} symref=HEAD:${head.symbolicTarget}`;
|
|
146
|
+
await writePktLine(writer, `${head.sha} HEAD\0${capsWithSymref}\n`);
|
|
147
|
+
for (const ref of refs) {
|
|
148
|
+
await writePktLine(writer, `${ref.sha} ${ref.name}\n`);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const first = refs[0];
|
|
152
|
+
if (first === undefined) {
|
|
153
|
+
throw new Error("advertise-refs: unreachable empty ref list");
|
|
154
|
+
}
|
|
155
|
+
await writePktLine(writer, `${first.sha} ${first.name}\0${capabilities}\n`);
|
|
156
|
+
for (let i = 1; i < refs.length; i++) {
|
|
157
|
+
const ref = refs[i];
|
|
158
|
+
if (ref === undefined) {
|
|
159
|
+
throw new Error("advertise-refs: unreachable undefined ref entry");
|
|
160
|
+
}
|
|
161
|
+
await writePktLine(writer, `${ref.sha} ${ref.name}\n`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await writeFlush(writer);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function advertiseStream(
|
|
169
|
+
refSource: RefSource,
|
|
170
|
+
principal: AdvertisePrincipal,
|
|
171
|
+
repoId: RepoId,
|
|
172
|
+
service: Service,
|
|
173
|
+
capabilities: string,
|
|
174
|
+
): ReadableStream<Uint8Array> {
|
|
175
|
+
return new ReadableStream<Uint8Array>({
|
|
176
|
+
async start(controller) {
|
|
177
|
+
const sink = new WritableStream<Uint8Array>({
|
|
178
|
+
write(chunk) {
|
|
179
|
+
controller.enqueue(chunk);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const writer = sink.getWriter();
|
|
183
|
+
try {
|
|
184
|
+
const [allRefs, head] = await Promise.all([
|
|
185
|
+
refSource.listRefs(principal, repoId),
|
|
186
|
+
refSource.resolveHead(principal, repoId),
|
|
187
|
+
]);
|
|
188
|
+
const refs = filterAndSort(allRefs, principal.tokenClaims.refPattern);
|
|
189
|
+
await writeAdvertisement(writer, service, capabilities, refs, head);
|
|
190
|
+
await writer.close();
|
|
191
|
+
controller.close();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
await writer.abort(err).catch(() => undefined);
|
|
194
|
+
controller.error(err);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function advertiseUploadPack(
|
|
201
|
+
refSource: RefSource,
|
|
202
|
+
principal: AdvertisePrincipal,
|
|
203
|
+
repoId: RepoId,
|
|
204
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
205
|
+
return advertiseStream(
|
|
206
|
+
refSource,
|
|
207
|
+
principal,
|
|
208
|
+
repoId,
|
|
209
|
+
"git-upload-pack",
|
|
210
|
+
UPLOAD_PACK_CAPABILITIES,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function advertiseReceivePack(
|
|
215
|
+
refSource: RefSource,
|
|
216
|
+
principal: AdvertisePrincipal,
|
|
217
|
+
repoId: RepoId,
|
|
218
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
219
|
+
return advertiseStream(
|
|
220
|
+
refSource,
|
|
221
|
+
principal,
|
|
222
|
+
repoId,
|
|
223
|
+
"git-receive-pack",
|
|
224
|
+
RECEIVE_PACK_CAPABILITIES,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
readPktLine,
|
|
4
|
+
writePktLine,
|
|
5
|
+
writeFlush,
|
|
6
|
+
writeDelim,
|
|
7
|
+
writeErr,
|
|
8
|
+
PKT_LINE_MAX_PAYLOAD,
|
|
9
|
+
} from "./pkt-line";
|
|
10
|
+
import type { PktLine } from "./pkt-line";
|
|
11
|
+
|
|
12
|
+
function head(lines: PktLine[]): PktLine {
|
|
13
|
+
const f = lines[0];
|
|
14
|
+
if (!f) throw new Error("expected at least one pkt-line");
|
|
15
|
+
return f;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function dataPayload(line: PktLine): Uint8Array {
|
|
19
|
+
if (line.kind !== "data") {
|
|
20
|
+
throw new Error(`expected data pkt-line, got ${line.kind}`);
|
|
21
|
+
}
|
|
22
|
+
return line.payload;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bytes(...parts: (string | Uint8Array)[]): Uint8Array {
|
|
26
|
+
const enc = new TextEncoder();
|
|
27
|
+
const chunks = parts.map((p) => (typeof p === "string" ? enc.encode(p) : p));
|
|
28
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
29
|
+
const out = new Uint8Array(total);
|
|
30
|
+
let off = 0;
|
|
31
|
+
for (const c of chunks) {
|
|
32
|
+
out.set(c, off);
|
|
33
|
+
off += c.length;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function streamOf(...frames: Uint8Array[]): ReadableStream<Uint8Array> {
|
|
39
|
+
let i = 0;
|
|
40
|
+
return new ReadableStream({
|
|
41
|
+
pull(controller) {
|
|
42
|
+
if (i < frames.length) {
|
|
43
|
+
controller.enqueue(frames[i++]);
|
|
44
|
+
} else {
|
|
45
|
+
controller.close();
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readAll(stream: ReadableStream<Uint8Array>): Promise<PktLine[]> {
|
|
52
|
+
const reader = stream.getReader();
|
|
53
|
+
const out: PktLine[] = [];
|
|
54
|
+
for (;;) {
|
|
55
|
+
const line = await readPktLine(reader);
|
|
56
|
+
if (line === null) break;
|
|
57
|
+
out.push(line);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("writePktLine round-trip", () => {
|
|
63
|
+
test("writes a standard data pkt-line with 4-byte hex length prefix", async () => {
|
|
64
|
+
const buf = new Uint8Array(
|
|
65
|
+
await collectWrites((w) => writePktLine(w, "hello\n")),
|
|
66
|
+
);
|
|
67
|
+
const expected = bytes("000ahello\n");
|
|
68
|
+
expect(Array.from(buf)).toEqual(Array.from(expected));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("readPktLine parses what writePktLine produces", async () => {
|
|
72
|
+
const payload = bytes(
|
|
73
|
+
await collectWrites((w) => writePktLine(w, "want abc123\n")),
|
|
74
|
+
);
|
|
75
|
+
const lines = await readAll(streamOf(payload));
|
|
76
|
+
expect(lines.length).toBe(1);
|
|
77
|
+
const line = head(lines);
|
|
78
|
+
expect(line.kind).toBe("data");
|
|
79
|
+
expect(new TextDecoder().decode(dataPayload(line))).toBe("want abc123\n");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("writeFlush emits 0000", async () => {
|
|
83
|
+
const buf = bytes(await collectWrites((w) => writeFlush(w)));
|
|
84
|
+
expect(new TextDecoder().decode(buf)).toBe("0000");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("writeDelim emits 0001", async () => {
|
|
88
|
+
const buf = bytes(await collectWrites((w) => writeDelim(w)));
|
|
89
|
+
expect(new TextDecoder().decode(buf)).toBe("0001");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("readPktLine recognises flush packet", async () => {
|
|
93
|
+
const lines = await readAll(streamOf(bytes("0000")));
|
|
94
|
+
expect(lines.length).toBe(1);
|
|
95
|
+
expect(head(lines).kind).toBe("flush");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("readPktLine recognises delim packet", async () => {
|
|
99
|
+
const lines = await readAll(streamOf(bytes("0001")));
|
|
100
|
+
expect(lines.length).toBe(1);
|
|
101
|
+
expect(head(lines).kind).toBe("delim");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("empty data line (length 0004) is allowed and yields zero-length payload", async () => {
|
|
105
|
+
const lines = await readAll(streamOf(bytes("0004")));
|
|
106
|
+
expect(lines.length).toBe(1);
|
|
107
|
+
const line = head(lines);
|
|
108
|
+
expect(line.kind).toBe("data");
|
|
109
|
+
expect(dataPayload(line).length).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("writeErr emits an ERR pkt-line in the canonical shape", async () => {
|
|
113
|
+
const buf = bytes(await collectWrites((w) => writeErr(w, "forbidden ref")));
|
|
114
|
+
expect(new TextDecoder().decode(buf)).toBe("0016ERR forbidden ref\n");
|
|
115
|
+
const lines = await readAll(streamOf(buf));
|
|
116
|
+
expect(lines.length).toBe(1);
|
|
117
|
+
const line = head(lines);
|
|
118
|
+
expect(line.kind).toBe("data");
|
|
119
|
+
expect(new TextDecoder().decode(dataPayload(line))).toBe(
|
|
120
|
+
"ERR forbidden ref\n",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("readPktLine error handling", () => {
|
|
126
|
+
test("malformed length (non-hex characters) throws", async () => {
|
|
127
|
+
await expect(readAll(streamOf(bytes("zzzzfoo")))).rejects.toThrow(
|
|
128
|
+
/malformed pkt-line length/i,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("length below 4 (and not 0000 or 0001) throws", async () => {
|
|
133
|
+
await expect(readAll(streamOf(bytes("0003")))).rejects.toThrow(
|
|
134
|
+
/reserved pkt-line length/i,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("truncated body throws", async () => {
|
|
139
|
+
await expect(readAll(streamOf(bytes("0010abc")))).rejects.toThrow(
|
|
140
|
+
/truncated pkt-line/i,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("truncated length header throws", async () => {
|
|
145
|
+
await expect(readAll(streamOf(bytes("00")))).rejects.toThrow(
|
|
146
|
+
/truncated pkt-line/i,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("oversize payload (length > 65520) throws", async () => {
|
|
151
|
+
// 65521 = 0xFFF1 — one more byte than max pkt-line length
|
|
152
|
+
await expect(readAll(streamOf(bytes("fff1")))).rejects.toThrow(
|
|
153
|
+
/oversize pkt-line/i,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("readPktLine across stream chunk boundaries", () => {
|
|
159
|
+
test("length header split across chunks", async () => {
|
|
160
|
+
const lines = await readAll(streamOf(bytes("00"), bytes("0aHELLO\n")));
|
|
161
|
+
expect(lines.length).toBe(1);
|
|
162
|
+
const line = head(lines);
|
|
163
|
+
expect(line.kind).toBe("data");
|
|
164
|
+
expect(new TextDecoder().decode(dataPayload(line))).toBe("HELLO\n");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("body split across chunks", async () => {
|
|
168
|
+
const lines = await readAll(streamOf(bytes("000aHEL"), bytes("LO\n")));
|
|
169
|
+
expect(lines.length).toBe(1);
|
|
170
|
+
expect(new TextDecoder().decode(dataPayload(head(lines)))).toBe("HELLO\n");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("single byte at a time", async () => {
|
|
174
|
+
const frame = bytes("000aHELLO\n");
|
|
175
|
+
const chunks = Array.from(frame).map((b) => new Uint8Array([b]));
|
|
176
|
+
const lines = await readAll(streamOf(...chunks));
|
|
177
|
+
expect(lines.length).toBe(1);
|
|
178
|
+
expect(new TextDecoder().decode(dataPayload(head(lines)))).toBe("HELLO\n");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("multiple frames mixed across chunk boundaries", async () => {
|
|
182
|
+
// "want abc" = 8 bytes -> frame length 0x0c ; "have def" same.
|
|
183
|
+
const lines = await readAll(
|
|
184
|
+
streamOf(bytes("000cwant"), bytes(" abc000chave def0000")),
|
|
185
|
+
);
|
|
186
|
+
expect(lines.map((l) => l.kind)).toEqual(["data", "data", "flush"]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("writePktLine size constraints", () => {
|
|
191
|
+
test("rejects payload larger than max pkt-line payload", async () => {
|
|
192
|
+
const oversized = "x".repeat(PKT_LINE_MAX_PAYLOAD + 1);
|
|
193
|
+
await expect(
|
|
194
|
+
collectWrites((w) => writePktLine(w, oversized)),
|
|
195
|
+
).rejects.toThrow(/oversize/i);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("accepts payload of exactly max pkt-line payload size", async () => {
|
|
199
|
+
const max = "x".repeat(PKT_LINE_MAX_PAYLOAD);
|
|
200
|
+
const buf = bytes(await collectWrites((w) => writePktLine(w, max)));
|
|
201
|
+
// 0xFFF0 = 65520
|
|
202
|
+
expect(new TextDecoder().decode(buf.slice(0, 4))).toBe("fff0");
|
|
203
|
+
expect(buf.length).toBe(4 + PKT_LINE_MAX_PAYLOAD);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function collectWrites(
|
|
208
|
+
fn: (w: WritableStreamDefaultWriter<Uint8Array>) => Promise<void>,
|
|
209
|
+
): Promise<Uint8Array> {
|
|
210
|
+
const chunks: Uint8Array[] = [];
|
|
211
|
+
const sink = new WritableStream<Uint8Array>({
|
|
212
|
+
write(chunk) {
|
|
213
|
+
chunks.push(chunk);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const writer = sink.getWriter();
|
|
217
|
+
await fn(writer);
|
|
218
|
+
await writer.close();
|
|
219
|
+
return bytes(...chunks);
|
|
220
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pkt-line framing as defined by Git's
|
|
3
|
+
* `Documentation/technical/protocol-common.txt`. A pkt-line is a
|
|
4
|
+
* 4-byte ASCII hex length header followed by `length - 4` bytes of
|
|
5
|
+
* payload. Two reserved special headers carry no payload:
|
|
6
|
+
*
|
|
7
|
+
* - `0000` flush packet (end of a logical message)
|
|
8
|
+
* - `0001` delim packet (separator inside a message)
|
|
9
|
+
*
|
|
10
|
+
* Maximum total frame length is 0xFFF0; thus the maximum payload
|
|
11
|
+
* size is 0xFFF0 - 4 = 65520.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HEADER_BYTES = 4;
|
|
15
|
+
const MAX_FRAME_LENGTH = 0xfff0;
|
|
16
|
+
|
|
17
|
+
export const PKT_LINE_MAX_PAYLOAD = MAX_FRAME_LENGTH - HEADER_BYTES; // 65520
|
|
18
|
+
|
|
19
|
+
export type PktLine =
|
|
20
|
+
| { kind: "flush" }
|
|
21
|
+
| { kind: "delim" }
|
|
22
|
+
| { kind: "data"; payload: Uint8Array };
|
|
23
|
+
|
|
24
|
+
function hexDigit(v: number): string {
|
|
25
|
+
if (v < 10) return String.fromCharCode(0x30 + v);
|
|
26
|
+
return String.fromCharCode(0x61 + (v - 10));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hex4(n: number): string {
|
|
30
|
+
return (
|
|
31
|
+
hexDigit((n >> 12) & 0xf) +
|
|
32
|
+
hexDigit((n >> 8) & 0xf) +
|
|
33
|
+
hexDigit((n >> 4) & 0xf) +
|
|
34
|
+
hexDigit(n & 0xf)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseHex4(s: string): number {
|
|
39
|
+
let v = 0;
|
|
40
|
+
for (let i = 0; i < 4; i++) {
|
|
41
|
+
const c = s.charCodeAt(i);
|
|
42
|
+
let d: number;
|
|
43
|
+
if (c >= 0x30 && c <= 0x39) {
|
|
44
|
+
d = c - 0x30;
|
|
45
|
+
} else if (c >= 0x61 && c <= 0x66) {
|
|
46
|
+
d = c - 0x61 + 10;
|
|
47
|
+
} else if (c >= 0x41 && c <= 0x46) {
|
|
48
|
+
d = c - 0x41 + 10;
|
|
49
|
+
} else {
|
|
50
|
+
throw new Error(`malformed pkt-line length: ${JSON.stringify(s)}`);
|
|
51
|
+
}
|
|
52
|
+
v = (v << 4) | d;
|
|
53
|
+
}
|
|
54
|
+
return v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
58
|
+
const out = new Uint8Array(a.length + b.length);
|
|
59
|
+
out.set(a, 0);
|
|
60
|
+
out.set(b, a.length);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sliceBytes(a: Uint8Array, start: number, end?: number): Uint8Array {
|
|
65
|
+
const e = end ?? a.length;
|
|
66
|
+
const out = new Uint8Array(e - start);
|
|
67
|
+
out.set(a.subarray(start, e), 0);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reader interface accepted by `readPktLine`: any object that yields
|
|
73
|
+
* `{ value, done }` results when `.read()` is called. The DOM
|
|
74
|
+
* `ReadableStreamDefaultReader<Uint8Array>` and the Node
|
|
75
|
+
* `node:stream/web` reader both satisfy this shape, so callers can
|
|
76
|
+
* pass `stream.getReader()` directly without type-juggling between
|
|
77
|
+
* the two reader flavours.
|
|
78
|
+
*/
|
|
79
|
+
export interface PktLineByteReader {
|
|
80
|
+
read(): Promise<{ done: boolean; value?: Uint8Array | undefined }>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Buffered reader that exposes byte-exact reads and tolerates
|
|
85
|
+
* underlying chunks of any size, including chunks that split a
|
|
86
|
+
* pkt-line header or body.
|
|
87
|
+
*/
|
|
88
|
+
class BufferedByteReader {
|
|
89
|
+
private buf: Uint8Array = new Uint8Array(0);
|
|
90
|
+
private done = false;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly reader: PktLineByteReader) {}
|
|
93
|
+
|
|
94
|
+
private async pull(): Promise<void> {
|
|
95
|
+
if (this.done) return;
|
|
96
|
+
const r = await this.reader.read();
|
|
97
|
+
if (r.done) {
|
|
98
|
+
this.done = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const value = r.value;
|
|
102
|
+
if (value && value.length > 0) {
|
|
103
|
+
this.buf =
|
|
104
|
+
this.buf.length === 0
|
|
105
|
+
? new Uint8Array(value)
|
|
106
|
+
: concatBytes(this.buf, value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async readExact(n: number): Promise<Uint8Array | null> {
|
|
111
|
+
while (this.buf.length < n) {
|
|
112
|
+
if (this.done) {
|
|
113
|
+
if (this.buf.length === 0) return null;
|
|
114
|
+
throw new Error(
|
|
115
|
+
`truncated pkt-line: needed ${n} bytes, got ${this.buf.length}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
await this.pull();
|
|
119
|
+
}
|
|
120
|
+
const out = sliceBytes(this.buf, 0, n);
|
|
121
|
+
this.buf = sliceBytes(this.buf, n);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async readExactOrThrow(n: number, what: string): Promise<Uint8Array> {
|
|
126
|
+
while (this.buf.length < n) {
|
|
127
|
+
if (this.done) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`truncated pkt-line ${what}: needed ${n} bytes, got ${this.buf.length}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
await this.pull();
|
|
133
|
+
}
|
|
134
|
+
const out = sliceBytes(this.buf, 0, n);
|
|
135
|
+
this.buf = sliceBytes(this.buf, n);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const READER_BUFFER = new WeakMap<PktLineByteReader, BufferedByteReader>();
|
|
141
|
+
|
|
142
|
+
function bufferFor(r: PktLineByteReader): BufferedByteReader {
|
|
143
|
+
let b = READER_BUFFER.get(r);
|
|
144
|
+
if (!b) {
|
|
145
|
+
b = new BufferedByteReader(r);
|
|
146
|
+
READER_BUFFER.set(r, b);
|
|
147
|
+
}
|
|
148
|
+
return b;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readPktLine(
|
|
152
|
+
r: PktLineByteReader,
|
|
153
|
+
): Promise<PktLine | null> {
|
|
154
|
+
const buf = bufferFor(r);
|
|
155
|
+
const header = await buf.readExact(HEADER_BYTES);
|
|
156
|
+
if (header === null) return null;
|
|
157
|
+
const h0 = header[0];
|
|
158
|
+
const h1 = header[1];
|
|
159
|
+
const h2 = header[2];
|
|
160
|
+
const h3 = header[3];
|
|
161
|
+
if (
|
|
162
|
+
h0 === undefined ||
|
|
163
|
+
h1 === undefined ||
|
|
164
|
+
h2 === undefined ||
|
|
165
|
+
h3 === undefined
|
|
166
|
+
) {
|
|
167
|
+
throw new Error("truncated pkt-line: short header");
|
|
168
|
+
}
|
|
169
|
+
const headerStr = String.fromCharCode(h0, h1, h2, h3);
|
|
170
|
+
const length = parseHex4(headerStr);
|
|
171
|
+
if (length === 0) return { kind: "flush" };
|
|
172
|
+
if (length === 1) return { kind: "delim" };
|
|
173
|
+
if (length === 2 || length === 3) {
|
|
174
|
+
throw new Error(`reserved pkt-line length: ${headerStr}`);
|
|
175
|
+
}
|
|
176
|
+
if (length > MAX_FRAME_LENGTH) {
|
|
177
|
+
throw new Error(`oversize pkt-line: length ${length}`);
|
|
178
|
+
}
|
|
179
|
+
const bodyLen = length - HEADER_BYTES;
|
|
180
|
+
const payload =
|
|
181
|
+
bodyLen === 0
|
|
182
|
+
? new Uint8Array(0)
|
|
183
|
+
: await buf.readExactOrThrow(bodyLen, "body");
|
|
184
|
+
return { kind: "data", payload };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function encodePayload(payload: string | Uint8Array): Uint8Array {
|
|
188
|
+
if (typeof payload === "string") return new TextEncoder().encode(payload);
|
|
189
|
+
return payload;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function writePktLine(
|
|
193
|
+
w: WritableStreamDefaultWriter<Uint8Array>,
|
|
194
|
+
payload: string | Uint8Array,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const body = encodePayload(payload);
|
|
197
|
+
if (body.length > PKT_LINE_MAX_PAYLOAD) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`oversize pkt-line payload: ${body.length} > ${PKT_LINE_MAX_PAYLOAD}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const length = body.length + HEADER_BYTES;
|
|
203
|
+
const header = new TextEncoder().encode(hex4(length));
|
|
204
|
+
const frame = new Uint8Array(header.length + body.length);
|
|
205
|
+
frame.set(header, 0);
|
|
206
|
+
frame.set(body, header.length);
|
|
207
|
+
await w.write(frame);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function writeFlush(
|
|
211
|
+
w: WritableStreamDefaultWriter<Uint8Array>,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
await w.write(new TextEncoder().encode("0000"));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function writeDelim(
|
|
217
|
+
w: WritableStreamDefaultWriter<Uint8Array>,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
await w.write(new TextEncoder().encode("0001"));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Emit an ERR pkt-line as used during upload-pack negotiation. Stock
|
|
224
|
+
* git surfaces this as `remote: <msg>; fatal: protocol error`. The
|
|
225
|
+
* frame body is `ERR <msg>\n`, encoded as a normal data pkt-line.
|
|
226
|
+
*
|
|
227
|
+
* Receive-pack reports failures with `ng <ref> <msg>` during the
|
|
228
|
+
* report-status phase instead; do not use writeErr there.
|
|
229
|
+
*/
|
|
230
|
+
export async function writeErr(
|
|
231
|
+
w: WritableStreamDefaultWriter<Uint8Array>,
|
|
232
|
+
msg: string,
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
await writePktLine(w, `ERR ${msg}\n`);
|
|
235
|
+
}
|