Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

152 lines · 7.3 KBRaw History
1// A stand-in for GitHub (or GitHub Enterprise) for the import end-to-end test:
2// the REST API under /api/v3 with Link pagination, real Git over smart HTTP
3// (`git http-backend`) and a Git LFS batch endpoint. Everything requires the
4// token it was created with, like a private repository would.
5import { spawn } from "node:child_process";
6import { createServer } from "node:http";
7import { createHash } from "node:crypto";
8
9/**
10 * @param {object} o
11 * @param {string} o.root directory holding <owner>/<name>.git bare repositories
12 * @param {string} o.token the token every request must carry
13 * @param {Record<string, unknown>} o.routes "GET /repos/o/r/labels" → JSON (arrays are paginated)
14 * @param {Map<string, Buffer>} o.lfs LFS objects by oid
15 * @param {string[]} [o.anonymous] repositories ("owner/name") anyone may read without a token, like public
16 * ones on GitHub; their admin endpoints (Actions variables and secrets, environments, branch protection)
17 * still answer 401 "Requires authentication" without one
18 */
19export function fakeGitHub({ root, token, routes, lfs, anonymous = [] }) {
20 const requests = [];
21 let base;
22 const authorized = (req) => {
23 const h = req.headers.authorization ?? "";
24 if (h === `Bearer ${token}` || h === `token ${token}`) return true;
25 const basic = /^Basic (.+)$/.exec(h);
26 return !!basic && Buffer.from(basic[1], "base64").toString().split(":").slice(1).join(":") === token;
27 };
28 const json = (res, status, body, headers = {}) => {
29 res.writeHead(status, { "content-type": "application/json", ...headers });
30 res.end(JSON.stringify(body));
31 };
32 const server = createServer(async (req, res) => {
33 const url = new URL(req.url, base);
34 const chunks = [];
35 for await (const c of req) chunks.push(c);
36 const body = Buffer.concat(chunks);
37 requests.push({ method: req.method, path: url.pathname, query: url.search });
38 // Public, unauthenticated GitHub endpoints: a user profile and their public gists (used by gist-proof account linking).
39 const publicGet = req.method === "GET" && /^\/api\/v3\/(users\/[^/]+(?:\/gists)?|gists\/[^/]+)$/.test(url.pathname);
40 const repoOf = /^\/(?:api\/v3\/repos\/)?([^/]+\/[^/]+?)(?:\.git)?(?:\/|$)/.exec(url.pathname)?.[1];
41 const anonymousRead = !req.headers.authorization && anonymous.includes(repoOf) && (req.method === "GET" || /\/git-upload-pack$/.test(url.pathname));
42 if (anonymousRead && /\/(actions|environments)(\/|$)|\/protection$/.test(url.pathname)) return json(res, 401, { message: "Requires authentication" });
43 if (!publicGet && !anonymousRead && !authorized(req)) return json(res, 401, { message: "Bad credentials" }, { "www-authenticate": 'Basic realm="GitHub"' });
44
45 // ---------------------------------------------------------------- Git LFS
46 const lfsBatch = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/info\/lfs\/objects\/batch$/.exec(url.pathname);
47 if (lfsBatch && req.method === "POST") {
48 const q = JSON.parse(body.toString());
49 const objects = q.objects.map((o) =>
50 lfs.has(o.oid)
51 ? { oid: o.oid, size: o.size, authenticated: true, actions: { download: { href: `${base}/lfs-objects/${o.oid}`, header: { Authorization: req.headers.authorization } } } }
52 : { oid: o.oid, size: o.size, error: { code: 404, message: "not found" } },
53 );
54 res.writeHead(200, { "content-type": "application/vnd.git-lfs+json" });
55 return res.end(JSON.stringify({ transfer: "basic", objects }));
56 }
57 const lfsGet = /^\/lfs-objects\/([0-9a-f]{64})$/.exec(url.pathname);
58 if (lfsGet) {
59 const data = lfs.get(lfsGet[1]);
60 if (!data) return json(res, 404, { message: "Not Found" });
61 res.writeHead(200, { "content-type": "application/octet-stream", "content-length": data.length });
62 return res.end(data);
63 }
64
65 // ---------------------------------------------------------------- Git smart HTTP
66 if (/^\/[^/]+\/[^/]+\.git\/(info\/refs|git-upload-pack)$/.test(url.pathname)) {
67 return cgi(req, res, url, body, root);
68 }
69
70 // ---------------------------------------------------------------- REST
71 if (!url.pathname.startsWith("/api/v3/")) return json(res, 404, { message: "Not Found" });
72 const path = url.pathname.slice("/api/v3".length);
73 const key = `${req.method} ${path}`;
74 const query = Object.fromEntries(url.searchParams);
75 const route = routes[key];
76 if (route === undefined) return json(res, 404, { message: "Not Found" });
77 const value = typeof route === "function" ? route(query) : route;
78 // Lists are paginated two items at a time to exercise Link headers.
79 const list = Array.isArray(value) ? value : value && typeof value === "object" && Array.isArray(value.variables ?? value.secrets) ? (value.variables ?? value.secrets) : null;
80 if (!list) return json(res, 200, value);
81 const per = Math.min(Number(query.per_page ?? 30), 2);
82 const page = Number(query.page ?? 1);
83 const items = list.slice((page - 1) * per, page * per);
84 const headers = {};
85 if (page * per < list.length) {
86 const next = new URL(url);
87 next.searchParams.set("page", String(page + 1));
88 headers.link = `<${next}>; rel="next"`;
89 }
90 const out = Array.isArray(value) ? items : { total_count: list.length, [value.variables ? "variables" : "secrets"]: items };
91 return json(res, 200, out, headers);
92 });
93 return {
94 requests,
95 async listen() {
96 await new Promise((r) => server.listen(0, "127.0.0.1", r));
97 server.unref();
98 base = `http://127.0.0.1:${server.address().port}`;
99 return base;
100 },
101 close: () => new Promise((r) => server.close(r)),
102 };
103}
104
105function cgi(req, res, url, body, root) {
106 const env = {
107 ...process.env,
108 GIT_PROJECT_ROOT: root,
109 GIT_HTTP_EXPORT_ALL: "1",
110 PATH_INFO: url.pathname,
111 REQUEST_METHOD: req.method,
112 QUERY_STRING: url.search.slice(1),
113 CONTENT_TYPE: req.headers["content-type"] ?? "",
114 CONTENT_LENGTH: String(body.length),
115 HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
116 GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
117 REMOTE_USER: "importer",
118 REMOTE_ADDR: "127.0.0.1",
119 };
120 const child = spawn("git", ["http-backend"], { env });
121 const out = [];
122 child.stdout.on("data", (d) => out.push(d));
123 child.stderr.on("data", () => {});
124 // git http-backend may close stdin before reading the whole body (e.g. GET info/refs); ignore the broken pipe.
125 child.stdin.on("error", () => {});
126 child.on("error", () => {});
127 child.stdin.end(body);
128 child.on("close", () => {
129 if (res.writableEnded) return;
130 const buf = Buffer.concat(out);
131 const sep = buf.indexOf("\r\n\r\n");
132 const head = buf.subarray(0, sep).toString();
133 const headers = {};
134 let status = 200;
135 for (const line of head.split("\r\n")) {
136 const i = line.indexOf(":");
137 const k = line.slice(0, i).trim();
138 const v = line.slice(i + 1).trim();
139 if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]);
140 else headers[k] = v;
141 }
142 res.writeHead(status, headers);
143 res.end(buf.subarray(sep + 4));
144 });
145}
146
147/** An LFS pointer file and the object it points to. */
148export function lfsObject(content) {
149 const data = Buffer.from(content);
150 const oid = createHash("sha256").update(data).digest("hex");
151 return { oid, data, pointer: `version https://git-lfs.github.com/spec/v1\noid sha256:${oid}\nsize ${data.length}\n` };
152}