packages/git funnelled every invocation through `catch { return null }`, so a
missing commit identity, a stale index.lock, a pathspec that matched nothing
and a git that was not installed all reached the user as "commit failed" — and
a git that could not run at all was reported as "not a git repository", which
is what a Windows contributor hit after installing Git for Git Bash only.
That null had a second meaning. `fileAtHead` returned it both for "this file
was not in HEAD" and for "we could not ask", so with git unavailable every
edited file was recorded `isNew` and undo deletes what an edit created: on the
codex and opencode paths, Revert deleted pre-existing tracked source files.
`HeadRead` splits the two, `FileDiff.noBaseline` carries the difference, and
`restoreFiles` checks it before `isNew` and refuses.
One `run()` now keeps exit code, stderr and spawn errno, and `failureText`
turns them into the one line a toast has room for. `stdout` is half its input,
not a fallback: `git commit` with nothing staged exits 1 and says so on stdout.
Also here, each its own small correctness fix:
- `cat-file --filters` instead of `show`, so `before` is the working-tree form.
The stored blob is LF whatever the tree is, and undo was writing that back
over CRLF files, rewriting every line ending in them.
- `:(literal)` pathspecs. `git add -- 'a[1].ts'` also stages an unrelated
`a1.ts`, so one edited file could sweep untouched work into the auto-commit.
- A binary blob is `unavailable` rather than decoded through U+FFFD and then
written back corrupted.
- `restoreFiles` cannot throw. `undo` is called straight out of the WebSocket
message handler, so an EACCES there took the whole daemon down.
- `push` and `gh pr create` get a timeout, a closed stdin and
`GIT_TERMINAL_PROMPT=0`. Git Credential Manager opens a GUI behind the
browser on Windows, and the awaiting handler never replied.
- `LC_ALL=C`, so classifying "absent from HEAD" does not depend on git's
messages being untranslated.
- One 64MB buffer ceiling for every call, not just the blob read.
`gitStatus()` is the answer that can be shown to someone: not installed, not a
work tree, bare, no commits yet, no identity. `ghStatus` gains the same
precision and a cwd, since `gh auth status` resolves its host from the remote.
The package had no tests at all, which is how the data-loss bug shipped. It has
34 now, including the reported scenario driven with PATH scrubbed of git.
277 lines
8.9 KiB
TypeScript
277 lines
8.9 KiB
TypeScript
/**
|
|
* The one test tier that boots the real server. Everything else in this
|
|
* package is a pure-function unit test; the access gates cannot be proven
|
|
* that way, because what matters is what reaches the wire — that a refused
|
|
* upgrade gets a literal 403 before it upgrades, that the `hello` frame is
|
|
* never written to a socket we rejected, and that a blocked Host never costs
|
|
* the upstream a request. Raw sockets, not a WebSocket client, so the
|
|
* assertions are on first bytes.
|
|
*
|
|
* This replays the reproduction from issue #15 (yarikbright).
|
|
*/
|
|
import { mkdtempSync } from "node:fs";
|
|
import { createServer, type Server } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { denyResponse } from "./access";
|
|
import { type RunningServer, startServer } from "./index";
|
|
|
|
const WS_KEY = "dGhlIHNhbXBsZSBub25jZQ==";
|
|
const HOLD_OPEN_MS = 600;
|
|
|
|
/** The first non-loopback IPv4 on this machine, when it has one. */
|
|
const lanAddress = Object.values(os.networkInterfaces())
|
|
.flat()
|
|
.find(
|
|
(iface) => iface && !iface.internal && iface.family === "IPv4"
|
|
)?.address;
|
|
|
|
interface Upstream {
|
|
port: number;
|
|
requests: string[];
|
|
server: Server;
|
|
upgrades: string[];
|
|
}
|
|
|
|
async function startUpstream(): Promise<Upstream> {
|
|
const requests: string[] = [];
|
|
const upgrades: string[] = [];
|
|
const server = createServer((req, res) => {
|
|
requests.push(`${req.method} ${req.url}`);
|
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
res.end("<html><body>upstream</body></html>");
|
|
});
|
|
server.on("upgrade", (req, socket) => {
|
|
upgrades.push(String(req.headers["sec-websocket-protocol"] ?? ""));
|
|
socket.end(
|
|
"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\nconnection: Upgrade\r\n\r\n"
|
|
);
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const { port } = server.address() as AddressInfo;
|
|
return { port, requests, server, upgrades };
|
|
}
|
|
|
|
/**
|
|
* Write one raw request, collect every byte until the peer closes. A
|
|
* completed upgrade never closes on its own, so the socket is destroyed
|
|
* after a beat and whatever arrived — status line, headers, the first
|
|
* WebSocket frames — is what gets asserted on.
|
|
*/
|
|
function exchange(port: number, request: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.connect({ host: "127.0.0.1", port }, () => {
|
|
socket.write(request);
|
|
});
|
|
let data = "";
|
|
socket.on("data", (chunk: Buffer) => {
|
|
data += chunk.toString("latin1");
|
|
});
|
|
socket.on("error", reject);
|
|
socket.on("close", () => resolve(data));
|
|
setTimeout(() => socket.destroy(), HOLD_OPEN_MS).unref();
|
|
});
|
|
}
|
|
|
|
function upgradeRequest(
|
|
pathname: string,
|
|
headers: Record<string, string>
|
|
): string {
|
|
return [
|
|
`GET ${pathname} HTTP/1.1`,
|
|
...Object.entries(headers).map(([key, value]) => `${key}: ${value}`),
|
|
"connection: Upgrade",
|
|
"upgrade: websocket",
|
|
"sec-websocket-version: 13",
|
|
`sec-websocket-key: ${WS_KEY}`,
|
|
"",
|
|
"",
|
|
].join("\r\n");
|
|
}
|
|
|
|
function refusedConnect(host: string, port: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.connect({ host, port, timeout: 1000 }, () => {
|
|
socket.destroy();
|
|
reject(new Error(`connected to ${host}:${port}`));
|
|
});
|
|
socket.on("error", () => resolve());
|
|
socket.on("timeout", () => {
|
|
socket.destroy();
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("the editor server, over raw sockets", () => {
|
|
let upstream: Upstream;
|
|
let server: RunningServer;
|
|
let port: number;
|
|
let cwd: string;
|
|
|
|
beforeAll(async () => {
|
|
cwd = mkdtempSync(path.join(os.tmpdir(), "airship-integration-"));
|
|
upstream = await startUpstream();
|
|
server = await startServer({ cwd, port: 0, targetPort: upstream.port });
|
|
port = Number(new URL(server.url).port);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
upstream.server.close();
|
|
});
|
|
|
|
describe("the control socket", () => {
|
|
it("refuses a cross-origin upgrade before it upgrades — the #15 repro", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", {
|
|
host: `localhost:${port}`,
|
|
origin: "http://evil.com",
|
|
})
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 403 ")).toBe(true);
|
|
// The half-connected failure this guards: a socket that was refused
|
|
// must never have been handed to the WebSocket server first.
|
|
expect(reply).not.toContain("101");
|
|
expect(reply).not.toContain("hello");
|
|
});
|
|
|
|
it("refuses Origin: null — a sandboxed iframe is not the absent case", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", {
|
|
host: `localhost:${port}`,
|
|
origin: "null",
|
|
})
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 403 ")).toBe(true);
|
|
expect(reply).not.toContain("hello");
|
|
});
|
|
|
|
it("admits a client with no Origin at all, and says hello", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", { host: `localhost:${port}` })
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 101 ")).toBe(true);
|
|
expect(reply).toContain('"type":"hello"');
|
|
});
|
|
|
|
it("admits a same-origin page", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", {
|
|
host: `localhost:${port}`,
|
|
origin: `http://localhost:${port}`,
|
|
})
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 101 ")).toBe(true);
|
|
expect(reply).toContain('"type":"hello"');
|
|
});
|
|
|
|
it("reports whether git works, beside the handshake", async () => {
|
|
// Beside `hello` rather than deferred: the transcript paints a finished
|
|
// turn's action menu immediately, and a menu that offers Commit for half a
|
|
// second before greying it out is worse than one that never offered it.
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", { host: `localhost:${port}` })
|
|
);
|
|
expect(reply).toContain('"type":"git:health"');
|
|
});
|
|
});
|
|
|
|
describe("the Host gate", () => {
|
|
it("blocks an unlisted Host before the upstream ever hears of it", async () => {
|
|
const before = upstream.requests.length;
|
|
const reply = await exchange(
|
|
port,
|
|
"GET / HTTP/1.1\r\nhost: evil.com\r\n\r\n"
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 403 ")).toBe(true);
|
|
expect(upstream.requests.length).toBe(before);
|
|
});
|
|
|
|
it("refuses an upgrade under an unlisted Host, byte for byte", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/__airship/ws", { host: "evil.com" })
|
|
);
|
|
expect(reply).toBe(denyResponse("Forbidden host"));
|
|
});
|
|
|
|
it("serves an IP-literal Host — literals cannot be rebound", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
`GET / HTTP/1.1\r\nhost: 127.0.0.1:${port}\r\n\r\n`
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 200 ")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("the HMR tunnel", () => {
|
|
it("still tunnels a same-origin dev-server upgrade, headers intact", async () => {
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/", {
|
|
host: `localhost:${port}`,
|
|
"sec-websocket-protocol": "vite-hmr",
|
|
})
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 101 ")).toBe(true);
|
|
expect(upstream.upgrades).toContain("vite-hmr");
|
|
});
|
|
|
|
it("refuses a cross-origin tunnel upgrade — the second door", async () => {
|
|
const before = upstream.upgrades.length;
|
|
const reply = await exchange(
|
|
port,
|
|
upgradeRequest("/", {
|
|
host: `localhost:${port}`,
|
|
origin: "http://evil.com",
|
|
"sec-websocket-protocol": "vite-hmr",
|
|
})
|
|
);
|
|
expect(reply).toBe(denyResponse("Forbidden origin"));
|
|
expect(upstream.upgrades.length).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe("the bind", () => {
|
|
it("is unreachable from the LAN by default", async () => {
|
|
if (!lanAddress) {
|
|
// No non-loopback interface on this machine (some CI runners).
|
|
return;
|
|
}
|
|
// `refusedConnect` rejects if the connection succeeds, failing the test.
|
|
await refusedConnect(lanAddress, port);
|
|
});
|
|
|
|
it("widens on request, while the printed URL stays clickable", async () => {
|
|
const wide = await startServer({
|
|
cwd,
|
|
host: "0.0.0.0",
|
|
port: 0,
|
|
targetPort: upstream.port,
|
|
});
|
|
try {
|
|
expect(new URL(wide.url).hostname).toBe("localhost");
|
|
const widePort = Number(new URL(wide.url).port);
|
|
const reply = await exchange(
|
|
widePort,
|
|
`GET / HTTP/1.1\r\nhost: localhost:${widePort}\r\n\r\n`
|
|
);
|
|
expect(reply.startsWith("HTTP/1.1 200 ")).toBe(true);
|
|
} finally {
|
|
await wide.close();
|
|
}
|
|
});
|
|
});
|
|
});
|