mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
fa5af59931
## What & why
Swept the repo for hand-rolled regex/string parsers that reimplement a
solved problem, and replaced the genuinely fragile ones with
battle-tested packages (or deps already in the tree). Each swap is
paired with a regression test for an input the old code got wrong.
| Site | Was | Now |
|------|-----|-----|
| `adb.ts` `resolveAvdPath`, `linux-preflight.ts` `readMb` | per-key
anchored regex over AVD `.ini` / `config.ini` | **`ini`** |
| `workspace-reader.ts` `extractEnvKeys` | split-on-first-`=` line loop
| **`dotenv`** (`parse`) |
| `uninstall.ts` `readBundledSkillName` | `^name:(.+)$` capture +
outer-quote strip | **`yaml`** (already a dep) |
| `mcp-configs.ts` `escapeRegExp` | hand-rolled char-class escape |
**`escape-string-regexp`** |
| `export.ts` xctrace version, `session-context.ts` RN minor |
`match(/(\d+)\./)` / `split(".")[1]` | **`semver`** `coerce` (already a
dep) |
| `source-resolver.ts` `parseDebugStack` | `at fn (file:l:c)` regex
(required both line+col) | **`stacktrace-parser`** |
### Bugs these fix (covered by new tests)
- **ini**: a quoted `path = "/Users/My Name/.../X.avd"` kept its quotes,
failed the `startsWith("/")` guard → AVD unresolved. Inline comments /
trailing junk after a sizing value made the anchored regex return `null`
→ sizing warning silently lost.
- **dotenv**: `export FOO=bar` produced the key `"export FOO"` (dropped
by the identifier filter); a multi-line quoted value leaked its
continuation lines (`FAKE=...`) as phantom keys.
- **yaml**: a `name:` value with a trailing `# comment` was taken
verbatim (comment included); odd quoting only had one outer pair
stripped.
- **stacktrace-parser**: a `:line`-only frame (no column, as Hermes/JSC
can emit) became an unusable `unknown` frame.
## Deliberately left alone (not "flaky", or no safe swap)
To avoid churn and regressions, I did **not** touch:
- **`uiautomator-parser.ts`** hand-rolled XML parser — a deliberate,
documented choice with a dedicated hardening suite, including a
*leniency* test for malformed dumps (stray `</node>`). A spec XML parser
would reject inputs this is built to recover from.
- **`ios-profiler/pipeline/xml-parser.ts`** (xctrace XML) — the most
tangled parser, but it has no real `.trace` fixtures and only thin
entity-decode tests. A rewrite can't be proven equivalent without
device-generated traces, so swapping it blind risks silently breaking
iOS CPU/hang/leak reports. Best done separately with a captured fixture.
- **`http.ts` `extractHostname`** — DNS-rebinding guard; `URL.hostname`
keeps the IPv6 brackets the loopback check compares without, so the
"fix" isn't simpler and the path is security-sensitive.
- **`adb.ts` `shellQuote`** — the POSIX single-quote escape is the
standard correct form.
- **`parseAdbDevices`, `android-screen.ts` `wm size`, `detect-app.ts`
dumpsys** — tool-specific CLI-output scraping with no clean package
equivalent (and documented edge-case handling).
- **`workspace-reader.ts` `extractMetroPort`** — a best-effort heuristic
over JS config; a correct replacement needs an AST parse, out of scope
here.
- **`config-parse.ts` Yarn/npm age-gate parsing** — a precise,
unit-tested domain parser; `ms` would introduce a bare-number ambiguity.
## Verification
- `tsc --build` clean across the workspace graph.
- Tests: tool-server **1205**, installer **264**, update-core **31** —
all pass (incl. the new regression tests).
- `prettier --check` clean on all changed files.
- Production esbuild bundle (`bundle-tools.cjs`) emits a valid
`tool-server.cjs`/`mcp-server.mjs` with
`ini`/`dotenv`/`stacktrace-parser` inlined (`node --check` OK). *(The
script's final step errored only on the auth-gated
`trace-processor.wasm` download, unrelated to bundling.)*
New runtime deps: `ini`, `dotenv`, `stacktrace-parser` (tool-server),
`escape-string-regexp` (installer) — all small, pure-JS. Lockfile change
is purely additive.
207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
parseDebugStack,
|
|
normalizeBundleUrl,
|
|
createSourceResolver,
|
|
} from "../../src/utils/debugger/source-resolver";
|
|
|
|
const mockFetch = vi.fn();
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
function symbolicateResponse(frame: { file?: string; lineNumber?: number; column?: number }) {
|
|
return new Response(JSON.stringify({ stack: [frame] }), {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
describe("parseDebugStack", () => {
|
|
it("parses stack frames correctly", () => {
|
|
const stack = `Error: react-stack-top-frame
|
|
at anonymous (http://localhost:8081/index.bundle?platform=ios&dev=true:100:20)
|
|
at App (http://localhost:8081/index.bundle?platform=ios&dev=true:200:10)
|
|
at react_stack_bottom_frame (native)`;
|
|
|
|
const frames = parseDebugStack(stack);
|
|
expect(frames).toHaveLength(3);
|
|
expect(frames[0].fn).toBe("anonymous");
|
|
expect(frames[0].line).toBe(100);
|
|
expect(frames[0].col).toBe(20);
|
|
|
|
expect(frames[1].fn).toBe("App");
|
|
expect(frames[1].line).toBe(200);
|
|
expect(frames[1].col).toBe(10);
|
|
});
|
|
|
|
it("returns frame[1] as the JSX call-site", () => {
|
|
const stack = `Error: react-stack-top-frame
|
|
at anonymous (http://localhost:8081/index.bundle:50:5)
|
|
at ParentComponent (http://localhost:8081/index.bundle:120:8)`;
|
|
|
|
const frames = parseDebugStack(stack);
|
|
expect(frames[1].fn).toBe("ParentComponent");
|
|
expect(frames[1].line).toBe(120);
|
|
});
|
|
|
|
it("parses a frame that has a line but no column", () => {
|
|
// The previous regex required `:line:col`; a `:line`-only location (which
|
|
// Hermes/JSC can emit) produced an unusable "unknown" frame.
|
|
const frames = parseDebugStack(`Error\n at App (http://localhost:8081/index.bundle:50)`);
|
|
expect(frames[0].fn).toBe("App");
|
|
expect(frames[0].line).toBe(50);
|
|
expect(frames[0].col).toBe(0);
|
|
});
|
|
|
|
it("trims a method name padded by an extra space before the paren", () => {
|
|
const frames = parseDebugStack(
|
|
`Error\n at anonymous (http://localhost:8081/index.bundle:1:2)`
|
|
);
|
|
expect(frames[0].fn).toBe("anonymous");
|
|
});
|
|
});
|
|
|
|
describe("normalizeBundleUrl", () => {
|
|
it("normalizes iOS //& to ?", () => {
|
|
const url = "http://localhost:8081/index.bundle//&platform=ios&dev=true";
|
|
const result = normalizeBundleUrl(url, 8081);
|
|
expect(result).toContain("?platform=ios");
|
|
expect(result).not.toContain("//&");
|
|
});
|
|
|
|
it("rewrites Android host to localhost", () => {
|
|
const url = "http://10.0.2.2:8081/index.bundle?platform=android&dev=true";
|
|
const result = normalizeBundleUrl(url, 8081);
|
|
expect(result).toContain("localhost");
|
|
expect(result).not.toContain("10.0.2.2");
|
|
});
|
|
|
|
it("rewrites port to the Metro port", () => {
|
|
const url = "http://localhost:9999/index.bundle?platform=ios";
|
|
const result = normalizeBundleUrl(url, 8081);
|
|
expect(result).toContain(":8081");
|
|
expect(result).not.toContain("9999");
|
|
});
|
|
});
|
|
|
|
describe("readSourceFragment containment + extension allowlist", () => {
|
|
let tmpRoot: string;
|
|
let resolver: ReturnType<typeof createSourceResolver>;
|
|
|
|
beforeAll(async () => {
|
|
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "argent-source-resolver-"));
|
|
await fs.writeFile(
|
|
path.join(tmpRoot, "App.tsx"),
|
|
"line1\nline2\nline3\nline4\nline5\n",
|
|
"utf8"
|
|
);
|
|
await fs.writeFile(path.join(tmpRoot, "secret.env"), "API_KEY=hunter2\n", "utf8");
|
|
// Sibling file outside tmpRoot — used for path-traversal probes.
|
|
await fs.writeFile(path.join(tmpRoot, "..", "outside-secret.txt"), "off-limits\n", "utf8");
|
|
resolver = createSourceResolver(8081, tmpRoot);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
await fs.rm(path.join(tmpRoot, "..", "outside-secret.txt"), { force: true });
|
|
});
|
|
|
|
it("reads a source file inside the project root", async () => {
|
|
const out = await resolver.readSourceFragment({ file: "App.tsx", line: 3, column: 0 }, 1);
|
|
expect(out).toContain("line3");
|
|
});
|
|
|
|
it("rejects an absolute path to a system file (~/.zshrc-style attack)", async () => {
|
|
const out = await resolver.readSourceFragment({ file: "/etc/hosts", line: 1, column: 0 }, 1);
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it("rejects a relative path that escapes projectRoot via `..`", async () => {
|
|
const out = await resolver.readSourceFragment(
|
|
{ file: "../outside-secret.txt", line: 1, column: 0 },
|
|
1
|
|
);
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it("rejects an in-project file with a non-source extension (.env)", async () => {
|
|
const out = await resolver.readSourceFragment({ file: "secret.env", line: 1, column: 0 }, 1);
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it("returns null for missing files instead of throwing", async () => {
|
|
const out = await resolver.readSourceFragment(
|
|
{ file: "does-not-exist.tsx", line: 1, column: 0 },
|
|
1
|
|
);
|
|
expect(out).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("createSourceResolver — symbolicate", () => {
|
|
const projectRoot = "/Users/dev/myapp";
|
|
const bundleUrl = "http://localhost:8081/index.bundle?platform=ios&dev=true";
|
|
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
it("keeps genuinely-mapped node_modules sources", async () => {
|
|
// A route component (expo-router / react-navigation) legitimately resolves
|
|
// into node_modules. Metro returns a real file path — it must be kept.
|
|
mockFetch.mockResolvedValueOnce(
|
|
symbolicateResponse({
|
|
file: `${projectRoot}/node_modules/expo-router/build/views/Navigator.js`,
|
|
lineNumber: 42,
|
|
column: 7,
|
|
})
|
|
);
|
|
|
|
const resolver = createSourceResolver(8081, projectRoot);
|
|
const result = await resolver.symbolicate(bundleUrl, 100, 20, "Navigator");
|
|
|
|
expect(result).toEqual({
|
|
file: "node_modules/expo-router/build/views/Navigator.js",
|
|
line: 42,
|
|
column: 7,
|
|
});
|
|
});
|
|
|
|
it("rejects unmapped bundle URLs echoed back by Metro", async () => {
|
|
// A failed symbolication echoes the bundle URL back unchanged.
|
|
mockFetch.mockResolvedValueOnce(
|
|
symbolicateResponse({ file: bundleUrl, lineNumber: 100, column: 20 })
|
|
);
|
|
|
|
const resolver = createSourceResolver(8081, projectRoot);
|
|
const result = await resolver.symbolicate(bundleUrl, 100, 20, "App");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("maps real app source paths relative to the project root", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
symbolicateResponse({
|
|
file: `${projectRoot}/app/index.tsx`,
|
|
lineNumber: 12,
|
|
column: 4,
|
|
})
|
|
);
|
|
|
|
const resolver = createSourceResolver(8081, projectRoot);
|
|
const result = await resolver.symbolicate(bundleUrl, 100, 20, "Index");
|
|
|
|
expect(result).toEqual({ file: "app/index.tsx", line: 12, column: 4 });
|
|
});
|
|
|
|
it("returns null when the symbolicate request throws", async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error("network down"));
|
|
|
|
const resolver = createSourceResolver(8081, projectRoot);
|
|
const result = await resolver.symbolicate(bundleUrl, 100, 20, "App");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|