Files
Ignacy Łątka 89ad80180c fix(map): strip DEL/C1 control bytes from the --json artifact
Device-sourced screen titles and element labels come from an untrusted
app. The stdout path already strips control bytes so a crafted
accessibility label can't smuggle an ANSI/OSC escape sequence into the
terminal, but the --json artifact relied on a false premise — 'JSON.stringify
escapes these'. It escapes only C0 (U+0000-U+001F); DEL and the C1 range
(U+007F-U+009F, the CSI/OSC introducers) pass through LITERAL, so a title
like "Settings<CSI>2J" wrote raw control bytes into argent-map.json that
fire a screen-clear / clipboard-write when the file is viewed with cat/less.

writeMapJson now recursively strips exactly the bytes JSON.stringify leaves
literal (DEL + C1) from every string in the state, leaving C0 to
JSON.stringify. Adds a test asserting no raw DEL/C1 byte reaches the file
while the visible text is preserved.
2026-07-21 11:23:24 +02:00

509 lines
19 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it, expect } from "vitest";
import {
parseMapArgs,
bootedMapCandidates,
formatDevicePicker,
formatProgressLine,
formatMapSummary,
writeMapJson,
asCancelledState,
} from "../src/map.js";
import { FlagParseException } from "../src/flag-parser.js";
describe("parseMapArgs", () => {
it("parses the bare positional bundle id with defaults", () => {
expect(parseMapArgs(["com.example.app"])).toEqual({
bundleId: "com.example.app",
udid: null,
maxScreens: null,
maxActions: null,
maxDepth: null,
budgetS: null,
deepLinks: [],
window: true,
json: false,
jsonPath: null,
help: false,
});
});
it("parses every flag", () => {
const args = parseMapArgs([
"com.example.app",
"--udid",
"ABC-123",
"--max-screens",
"40",
"--max-actions",
"8",
"--max-depth",
"3",
"--budget",
"120",
"--no-window",
"--json=out/graph.json",
"--deep-link",
"myapp://home",
"--deep-link=myapp://profile",
]);
expect(args).toEqual({
bundleId: "com.example.app",
udid: "ABC-123",
maxScreens: 40,
maxActions: 8,
maxDepth: 3,
budgetS: 120,
deepLinks: ["myapp://home", "myapp://profile"],
window: false,
json: true,
jsonPath: "out/graph.json",
help: false,
});
});
it("--json without a path keeps the default output", () => {
const args = parseMapArgs(["com.example.app", "--json"]);
expect(args.json).toBe(true);
expect(args.jsonPath).toBeNull();
});
it("--json does not swallow a trailing flag as its path", () => {
const args = parseMapArgs(["com.example.app", "--json", "--no-window"]);
expect(args.json).toBe(true);
expect(args.jsonPath).toBeNull();
expect(args.window).toBe(false);
});
it("--json before the bundle id treats the next token as the bundle id", () => {
// Bare `--json` never consumes a following token (the path must be attached
// as `--json=<path>`), so `argent map --json com.example.app` targets the
// app regardless of ordering.
const args = parseMapArgs(["--json", "com.example.app"]);
expect(args.json).toBe(true);
expect(args.jsonPath).toBeNull();
expect(args.bundleId).toBe("com.example.app");
});
// Finding 1 [M]: the explicit `--json=<path>` form must set the output path
// regardless of where it sits relative to the positional bundle id. The old
// parser only consumed a space-separated path *after* the positional and
// rejected `--json=...` outright as an unknown flag.
it("--json=<path> sets the output path before the bundle id", () => {
const args = parseMapArgs(["--json=out.json", "com.example.app"]);
expect(args.json).toBe(true);
expect(args.jsonPath).toBe("out.json");
expect(args.bundleId).toBe("com.example.app");
});
it("--json=<path> sets the output path after the bundle id", () => {
const args = parseMapArgs(["com.example.app", "--json=out/graph.json"]);
expect(args.json).toBe(true);
expect(args.jsonPath).toBe("out/graph.json");
expect(args.bundleId).toBe("com.example.app");
});
it("--json= with an empty path fails loudly", () => {
expect(() => parseMapArgs(["com.example.app", "--json="])).toThrow(/expects a path/);
});
it("sets help for --help and -h", () => {
expect(parseMapArgs(["--help"]).help).toBe(true);
expect(parseMapArgs(["-h"]).help).toBe(true);
});
it("rejects a missing flag value", () => {
expect(() => parseMapArgs(["com.example.app", "--udid"])).toThrow(FlagParseException);
expect(() => parseMapArgs(["com.example.app", "--max-screens"])).toThrow(/requires a value/);
});
it("rejects non-positive and non-integer numeric values", () => {
expect(() => parseMapArgs(["com.example.app", "--max-screens", "abc"])).toThrow(
/positive integer/
);
expect(() => parseMapArgs(["com.example.app", "--max-depth", "0"])).toThrow(/positive integer/);
expect(() => parseMapArgs(["com.example.app", "--budget", "-5"])).toThrow(/positive integer/);
expect(() => parseMapArgs(["com.example.app", "--max-actions", "2.5"])).toThrow(
/positive integer/
);
});
it("rejects over-cap numeric values here, not as a server-side ZodError blob", () => {
expect(() => parseMapArgs(["com.example.app", "--max-screens", "200"])).toThrow(
/--max-screens must be at most 100/
);
expect(() => parseMapArgs(["com.example.app", "--max-actions", "50"])).toThrow(
/--max-actions must be at most 30/
);
expect(() => parseMapArgs(["com.example.app", "--max-depth", "20"])).toThrow(
/--max-depth must be at most 10/
);
expect(() => parseMapArgs(["com.example.app", "--budget", "3600"])).toThrow(
/--budget must be at most 1800/
);
// The cap is inclusive — the boundary value is accepted.
expect(parseMapArgs(["com.example.app", "--max-screens", "100"]).maxScreens).toBe(100);
});
it("rejects more than 20 --deep-link urls here, not as a server-side ZodError blob", () => {
const links = Array.from({ length: 21 }, (_, i) => `--deep-link=myapp://s${i}`);
expect(() => parseMapArgs(["com.example.app", ...links])).toThrow(
/--deep-link accepts at most 20 urls, got 21/
);
// The cap is inclusive — exactly 20 is accepted.
const ok = Array.from({ length: 20 }, (_, i) => `--deep-link=myapp://s${i}`);
expect(parseMapArgs(["com.example.app", ...ok]).deepLinks).toHaveLength(20);
});
it("rejects unknown flags and extra positionals", () => {
expect(() => parseMapArgs(["com.example.app", "--frobnicate"])).toThrow(/Unknown flag/);
expect(() => parseMapArgs(["com.example.app", "com.other.app"])).toThrow(/extra argument/);
});
// Finding 3 [L]: a value-taking flag must not swallow a following flag token
// as its value. Previously `--udid --max-screens` set udid to "--max-screens"
// (and `--udid --max-screens` alone silently parsed with no bundle id at all).
it("rejects a following flag token as a flag's value", () => {
expect(() => parseMapArgs(["--udid", "--max-screens"])).toThrow(/expects a value/);
expect(() => parseMapArgs(["--udid", "--max-screens", "5", "com.foo"])).toThrow(
/expects a value/
);
});
// The guard above must still let a legitimate negative number reach the
// numeric flags, which reject it with the clearer "positive integer" message
// rather than a misleading "expects a value".
it("still routes a negative numeric value to the positive-integer check", () => {
expect(() => parseMapArgs(["com.example.app", "--budget", "-5"])).toThrow(/positive integer/);
});
// Deep-link seeding: --deep-link is REPEATABLE and collects into deepLinks,
// forwarded to map-app as additional crawl entry points (an app is a graph,
// not a tree). The attached --deep-link=<url> form mirrors --json=<path>.
it("defaults deepLinks to an empty array", () => {
expect(parseMapArgs(["com.example.app"]).deepLinks).toEqual([]);
});
it("collects repeated --deep-link values into deepLinks in order", () => {
const args = parseMapArgs(["com.example.app", "--deep-link", "a", "--deep-link", "b"]);
expect(args.deepLinks).toEqual(["a", "b"]);
expect(args.bundleId).toBe("com.example.app");
});
it("accepts the attached --deep-link=<url> form", () => {
const args = parseMapArgs(["com.example.app", "--deep-link=myapp://home"]);
expect(args.deepLinks).toEqual(["myapp://home"]);
});
it("mixes the bare and attached deep-link forms (an =-bearing url survives)", () => {
const args = parseMapArgs([
"com.example.app",
"--deep-link",
"myapp://a",
"--deep-link=myapp://b?x=1",
]);
expect(args.deepLinks).toEqual(["myapp://a", "myapp://b?x=1"]);
});
it("--deep-link= with an empty value throws", () => {
expect(() => parseMapArgs(["com.example.app", "--deep-link="])).toThrow(FlagParseException);
expect(() => parseMapArgs(["com.example.app", "--deep-link="])).toThrow(/expects a url/);
});
it("--deep-link with no following token throws", () => {
expect(() => parseMapArgs(["com.example.app", "--deep-link"])).toThrow(FlagParseException);
expect(() => parseMapArgs(["com.example.app", "--deep-link"])).toThrow(/requires a value/);
});
it("rejects an empty bare --deep-link value", () => {
expect(() => parseMapArgs(["com.example.app", "--deep-link", ""])).toThrow(FlagParseException);
});
it("--deep-link does not swallow a following flag token as its url", () => {
expect(() => parseMapArgs(["com.example.app", "--deep-link", "--no-window"])).toThrow(
/expects a value/
);
});
it("combines deep links with the positional bundle id and other flags in any order", () => {
const args = parseMapArgs([
"--deep-link=myapp://a",
"com.example.app",
"--max-screens",
"10",
"--deep-link",
"myapp://b",
"--no-window",
]);
expect(args.bundleId).toBe("com.example.app");
expect(args.deepLinks).toEqual(["myapp://a", "myapp://b"]);
expect(args.maxScreens).toBe(10);
expect(args.window).toBe(false);
});
});
describe("bootedMapCandidates", () => {
const devices = [
{ platform: "ios", udid: "IOS-BOOTED", name: "iPhone 16 Pro", state: "Booted" },
{ platform: "ios", udid: "IOS-OFF", name: "iPhone 16", state: "Shutdown" },
{
platform: "ios",
udid: "TV-BOOTED",
name: "Apple TV",
state: "Booted",
runtimeKind: "tv",
},
{
platform: "android",
serial: "emulator-5554",
state: "device",
avdName: "Pixel_8",
model: "sdk_gphone64",
kind: "emulator",
},
{ platform: "android", serial: "emulator-5556", state: "offline", avdName: "Pixel_7" },
{
platform: "android",
serial: "adb-tv",
state: "device",
avdName: "TV_AVD",
runtimeKind: "tv",
},
{ platform: "chromium", id: "chromium-9222", state: "running" },
{ platform: "vega", serial: "vega-1", state: "running" },
];
it("keeps only booted iOS simulators and ready Android devices", () => {
expect(bootedMapCandidates(devices)).toEqual([
{ id: "IOS-BOOTED", label: "IOS-BOOTED iPhone 16 Pro (ios)" },
{ id: "emulator-5554", label: "emulator-5554 Pixel_8 (android)" },
]);
});
it("labels an Android physical device by model when it has no AVD name", () => {
const [candidate] = bootedMapCandidates([
{ platform: "android", serial: "R5CT1", state: "device", avdName: null, model: "Pixel 9" },
]);
expect(candidate).toEqual({ id: "R5CT1", label: "R5CT1 Pixel 9 (android)" });
});
it("tolerates junk shapes", () => {
expect(bootedMapCandidates(undefined)).toEqual([]);
expect(bootedMapCandidates("nope")).toEqual([]);
expect(bootedMapCandidates([null, 42, {}, { platform: "ios", state: "Booted" }])).toEqual([]);
});
});
describe("formatDevicePicker", () => {
it("strips control bytes from device-sourced labels (terminal-injection guard)", () => {
// A physical Android device's ro.product.model is device-controlled; an
// ESC/OSC payload in it must not reach the terminal when the picker lists it.
const candidates = bootedMapCandidates([
{ platform: "ios", udid: "IOS-1", state: "Booted", name: "iPhone 16" },
{
platform: "android",
serial: "R5CT1",
state: "device",
avdName: null,
model: "Pixel\x1b]0;pwned\x07\x1b[2J",
},
]);
const out = formatDevicePicker(candidates);
// The ESC/BEL bytes are gone (structural newlines between rows are fine);
// the visible tail of each sequence stays as inert text.
expect(out.includes("\x1b")).toBe(false);
expect(out.includes("\x07")).toBe(false);
expect(out).toContain("R5CT1 Pixel]0;pwned[2J (android)");
expect(out).toContain("IOS-1 iPhone 16 (ios)");
expect(out.startsWith("map: several booted devices found")).toBe(true);
});
});
describe("formatProgressLine", () => {
it("renders a discovered screen prominently", () => {
const line = formatProgressLine(
{ kind: "screen", nodeId: "s3", title: "Settings", depth: 2, screens: 4 },
30
);
expect(line).toEqual({ text: "[4/30] Settings (depth 2)", dim: false });
});
it("renders action / restart / phase events as dim one-liners", () => {
expect(
formatProgressLine(
{ kind: "action", nodeId: "s1", label: "Log in", explored: 2, total: 9 },
30
)
).toEqual({ text: " · Log in (2/9)", dim: true });
expect(formatProgressLine({ kind: "restart", reason: "replay diverged" }, 30)).toEqual({
text: " ↻ restart: replay diverged",
dim: true,
});
expect(formatProgressLine({ kind: "phase", message: "launching app" }, 30)).toEqual({
text: " launching app",
dim: true,
});
});
it("returns null for unknown shapes", () => {
expect(formatProgressLine(null, 30)).toBeNull();
expect(formatProgressLine("screen", 30)).toBeNull();
expect(formatProgressLine({ no: "kind" }, 30)).toBeNull();
expect(formatProgressLine({ kind: "novel-event" }, 30)).toBeNull();
});
it("strips control bytes from device-sourced titles and labels (terminal-injection guard)", () => {
// A crawled app's accessibility text is untrusted; an ESC/OSC payload must
// not reach the terminal. \x1b]52 is a clipboard-write, \x1b[2J a screen
// clear, \x07 the BEL that terminates an OSC.
const evilTitle = "Home\x1b]52;c;cGF5bG9hZA==\x07\x1b[2J";
const screen = formatProgressLine(
{ kind: "screen", nodeId: "s0", title: evilTitle, depth: 0, screens: 1 },
30
);
// Exact equality pins that every ESC/BEL byte is gone (the visible tail of
// each sequence remains as inert text).
expect(screen!.text).toBe("[1/30] Home]52;c;cGF5bG9hZA==[2J (depth 0)");
const action = formatProgressLine(
{ kind: "action", nodeId: "s0", label: "Tap\x1b[31mme", explored: 1, total: 2 },
30
);
expect(action!.text).toBe(" · Tap[31mme (1/2)");
});
});
describe("formatMapSummary", () => {
const stats = { screens: 12, edges: 18, restarts: 2, elapsedMs: 93_400 };
it("words a completed crawl", () => {
expect(formatMapSummary("completed", stats)).toBe(
"Mapped 12 screens, 18 edges, 2 restarts in 93.4s"
);
});
it("words a cancelled crawl as a partial map", () => {
expect(formatMapSummary("cancelled", stats)).toBe(
"Cancelled — partial map: 12 screens, 18 edges, 2 restarts in 93.4s"
);
});
it("words a failed crawl as a partial map", () => {
expect(formatMapSummary("failed", stats)).toBe(
"Failed — partial map: 12 screens, 18 edges, 2 restarts in 93.4s"
);
});
it("singularizes counts of one", () => {
expect(
formatMapSummary("completed", { screens: 1, edges: 1, restarts: 1, elapsedMs: 1000 })
).toBe("Mapped 1 screen, 1 edge, 1 restart in 1.0s");
});
});
// Finding 2 [M]: cancelling a crawl must still write the requested --json
// output. Both the normal-finish and cancel paths now route through this shared
// writer (which took no fetched-state argument before the fix — the cancel path
// simply returned before reaching the write block, discarding the artifact).
describe("writeMapJson", () => {
let dir: string;
let cwd: string;
beforeEach(() => {
cwd = process.cwd();
dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-map-json-"));
});
afterEach(() => {
process.chdir(cwd);
fs.rmSync(dir, { recursive: true, force: true });
});
it("writes the state as pretty JSON with a trailing newline to the given path", () => {
const state = { nodes: [{ id: "a" }], edges: [] };
const outPath = path.join(dir, "graph.json");
const written = writeMapJson(state, outPath);
expect(written).toBe(outPath);
const text = fs.readFileSync(outPath, "utf8");
expect(text.endsWith("}\n")).toBe(true);
expect(JSON.parse(text)).toEqual(state);
});
it("creates missing parent directories", () => {
const outPath = path.join(dir, "nested", "deep", "graph.json");
writeMapJson({ ok: true }, outPath);
expect(fs.existsSync(outPath)).toBe(true);
});
it("strips the DEL/C1 control bytes device text smuggles past JSON.stringify", () => {
// JSON.stringify escapes only C0 (U+0000-U+001F); DEL and the C1 range
// (U+007F-U+009F — e.g. the CSI/OSC introducers) survive literally and would
// fire an escape sequence when the artifact is viewed with cat/less. A
// malicious crawled app's AX label reaches node.title / edge action.label, so
// the writer must strip them, matching the stdout sanitizer.
const csi = String.fromCharCode(0x9b);
const osc = String.fromCharCode(0x9d);
const del = String.fromCharCode(0x7f);
const state = {
nodes: [{ id: "s0", title: `Settings${csi}2J${del}` }],
edges: [{ from: "s0", to: "s1", action: { label: `Tap${osc}0;evil` } }],
};
const outPath = path.join(dir, "graph.json");
writeMapJson(state, outPath);
const text = fs.readFileSync(outPath, "utf8");
// No raw DEL/C1 byte survived into the file.
const leaks = [...text].some((c) => c.charCodeAt(0) >= 0x7f && c.charCodeAt(0) <= 0x9f);
expect(leaks).toBe(false);
// The visible text is preserved — only the control bytes are removed.
const parsed = JSON.parse(text) as {
nodes: { title: string }[];
edges: { action: { label: string } }[];
};
expect(parsed.nodes[0]!.title).toBe("Settings2J");
expect(parsed.edges[0]!.action.label).toBe("Tap0;evil");
});
it("defaults to ./argent-map.json when no path is given", () => {
process.chdir(dir);
const written = writeMapJson({ ok: true }, null);
// Resolve the expected path against the same cwd writeMapJson used — on
// macOS os.tmpdir() is a /var → /private/var symlink, so comparing against
// the raw `dir` would spuriously differ.
expect(written).toBe(path.resolve("argent-map.json"));
expect(fs.existsSync(written)).toBe(true);
});
});
describe("asCancelledState", () => {
it("stamps a snapshot still marked running as cancelled (Ctrl-C race), keeping the rest", () => {
// The abort can win the race against the server's between-steps finalize, so
// the state read right after Ctrl-C still says "running"; the artifact the
// user asked for must not misreport a cancelled crawl as in-progress.
const raced = {
status: "running",
finishedAt: null,
stats: { screens: 3 },
nodes: [{ id: "s0" }],
};
const out = asCancelledState(raced) as typeof raced;
expect(out.status).toBe("cancelled");
expect(out.stats).toEqual({ screens: 3 });
expect(out.nodes).toEqual([{ id: "s0" }]);
expect(raced.status).toBe("running"); // input not mutated
});
it("leaves an already-finalized snapshot untouched", () => {
const cancelled = { status: "cancelled", finishedAt: 123 };
expect(asCancelledState(cancelled)).toBe(cancelled);
const completed = { status: "completed", finishedAt: 456 };
expect(asCancelledState(completed)).toBe(completed);
});
it("tolerates non-object states", () => {
expect(asCancelledState(null)).toBeNull();
expect(asCancelledState("nope")).toBe("nope");
});
});