Files
yaak-mountain-loop/apps/yaak-proxy/lib/decrypt.ts
T
Gregory SchierandClaude Fable 5.2 35058cc3be Yaak Proxy: prose pass over the UI copy
Apply the approved wording table: labels stay short, explanations are one
full sentence about the user's traffic, and detail moves behind an info
icon on the first-run card, the body banners, the Settings Helper row and
the rejected card. One word per thing: traffic and flows, recorded,
truncated, domains, searches, the CLI, Retry.

Backend messages that reach the window change with them, with the tests
and harness fixtures that assert on the old text. The two working files
for the pass are removed from the branch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:41:49 -07:00

481 lines
17 KiB
TypeScript

import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import type { FilterOptions } from "@yaakapp-internal/ui/editor";
/**
* The decrypt list as the engine reads it: `host:` and `app:` entries, `*` for everything, any
* of them after `!` to exclude. `DecryptEntry` and `DecryptPolicy` in `crates/yaak-proxy`, which
* decide each connection, follow the same rules; this copy marks the sidebar and the detail.
*/
export const EVERYTHING = "*";
export interface ParsedEntry {
negated: boolean;
kind: "everything" | "host" | "app";
/** A host pattern, lowercased, or an app's name or bundle id as written */
pattern: string;
/** The port a host entry is limited to */
port: number | null;
}
/**
* An entry as typed: with its prefix, or a bare hostname, which is a `host:` entry. A URL is
* taken as its host. Null when it is not one.
*/
export function parseEntry(input: string): ParsedEntry | null {
let rest = input.trim();
const negated = rest.startsWith("!");
if (negated) rest = rest.slice(1).trimStart();
if (rest === "") return null;
if (rest === EVERYTHING) return { negated, kind: "everything", pattern: "", port: null };
if (/^app:/i.test(rest)) {
const app = unquote(rest.slice(4).trim());
return app === "" ? null : { negated, kind: "app", pattern: app, port: null };
}
const host = unquote((/^host:/i.test(rest) ? rest.slice(5) : rest).trim());
const parsed = parseHost(host);
return parsed == null ? null : { negated, kind: "host", ...parsed };
}
function unquote(s: string): string {
return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).trim() : s;
}
function parseHost(input: string): { pattern: string; port: number | null } | null {
let host = input;
const scheme = host.indexOf("://");
if (scheme !== -1) {
host = host.slice(scheme + 3).split(/[/?#]/)[0] ?? "";
host = host.slice(host.lastIndexOf("@") + 1);
}
let pattern: string;
let port: number | null = null;
if (host.startsWith("[")) {
const end = host.indexOf("]");
if (end === -1) return null;
pattern = host.slice(1, end);
const after = host.slice(end + 1);
if (after !== "") {
if (!after.startsWith(":")) return null;
port = parsePort(after.slice(1));
if (port == null) return null;
}
} else {
const colon = host.lastIndexOf(":");
const name = colon === -1 ? host : host.slice(0, colon);
// One colon is a port; more, a bare IPv6 address.
if (colon !== -1 && !name.includes(":")) {
port = parsePort(host.slice(colon + 1));
if (port == null) return null;
pattern = name;
} else {
pattern = host;
}
}
pattern = pattern.replace(/\.+$/, "").toLowerCase();
if (pattern === "" || !/^[a-z0-9\-._*:]+$/.test(pattern)) return null;
return { pattern, port };
}
function parsePort(s: string): number | null {
if (!/^\d+$/.test(s)) return null;
const n = Number(s);
return n <= 65535 ? n : null;
}
/** The entry as stored: `host:api.example.com`, `!app:Slack`, `*` */
export function formatEntry(e: ParsedEntry): string {
const not = e.negated ? "!" : "";
if (e.kind === "everything") return `${not}${EVERYTHING}`;
if (e.kind === "app") return `${not}app:${e.pattern}`;
if (e.port == null) return `${not}host:${e.pattern}`;
const host = e.pattern.includes(":") ? `[${e.pattern}]` : e.pattern;
return `${not}host:${host}:${e.port}`;
}
/** An entry as typed, normalized as the engine stores it, or null when it is not one */
export function normalizeEntry(input: string): string | null {
const parsed = parseEntry(input);
return parsed == null ? null : formatEntry(parsed);
}
/** How the list names an entry: `*` is Everything, anything else as written */
export function entryLabel(entry: string): string {
if (entry === EVERYTHING) return "Everything";
if (entry === `!${EVERYTHING}`) return "Nothing";
return entry;
}
/** What a connection is: where it goes and, when known, the app that sent it */
export interface DecryptQuery {
host: string;
port?: number | null;
app?: { name: string; bundleId: string | null } | null;
}
function glob(pattern: string, text: string): boolean {
let pi = 0;
let ti = 0;
let star: [number, number] | null = null;
while (ti < text.length) {
if (pi < pattern.length && pattern[pi] === "*") {
star = [pi, ti];
pi++;
} else if (pi < pattern.length && pattern[pi] === text[ti]) {
pi++;
ti++;
} else if (star != null) {
pi = star[0] + 1;
ti = star[1] + 1;
star = [star[0], star[1] + 1];
} else {
return false;
}
}
return pattern
.slice(pi)
.split("")
.every((c) => c === "*");
}
/** `*.example.com` matches `example.com` too, as the search's `@host` does */
function hostMatches(pattern: string, host: string): boolean {
const h = host
.replace(/^\[|\]$/g, "")
.replace(/\.+$/, "")
.toLowerCase();
return glob(pattern, h) || (pattern.startsWith("*.") && glob(pattern.slice(2), h));
}
function matches(e: ParsedEntry, q: DecryptQuery): boolean {
switch (e.kind) {
case "everything":
return true;
case "host":
return (e.port == null || e.port === (q.port ?? 443)) && hostMatches(e.pattern, q.host);
case "app": {
if (q.app == null) return false;
const p = e.pattern.toLowerCase();
return (
glob(p, q.app.name.toLowerCase()) ||
(q.app.bundleId != null && glob(p, q.app.bundleId.toLowerCase()))
);
}
}
}
/** Whether one entry, as stored, names `q`, whichever way it points */
export function entryMatches(entry: string, q: DecryptQuery): boolean {
const parsed = parseEntry(entry);
return parsed != null && matches(parsed, q);
}
function specificity(e: ParsedEntry): number {
if (e.kind === "everything") return 0;
if (e.kind === "app") return Number.POSITIVE_INFINITY;
// A site's `*.` counts for nothing, as `*.apple.com` matches `apple.com` itself.
const pattern = e.pattern.startsWith("*.") ? e.pattern.slice(2) : e.pattern;
return pattern.replaceAll("*", "").length + (e.port == null ? 0 : 1);
}
/**
* Whether the list decrypts `q`: a positive entry matches and no negative one does, wherever
* each sits. A built-in exclusion yields to a positive entry at least as specific as itself.
*/
export function decrypts(list: DecryptListEntry[], q: DecryptQuery): boolean {
const parsed = list.flatMap((e) => {
const p = parseEntry(e.entry);
return p == null ? [] : [{ ...p, builtIn: e.builtIn }];
});
let best = -1;
for (const e of parsed) {
if (!e.negated && matches(e, q)) best = Math.max(best, specificity(e));
}
if (best < 0) return false;
return !parsed.some((e) => e.negated && matches(e, q) && (!e.builtIn || specificity(e) > best));
}
/** Some entry decrypts something: the list is not only exclusions */
export function decryptsAnything(list: DecryptListEntry[]): boolean {
return list.some((e) => !e.entry.trim().startsWith("!"));
}
/** What a list entry is called in a button: its host or pattern, its app, or Everything */
export function entrySubject(entry: string): string {
const parsed = parseEntry(entry);
if (parsed == null) return entry;
if (parsed.kind === "everything") return "Everything";
if (parsed.kind === "app") return parsed.pattern;
return formatEntry({ ...parsed, negated: false }).slice("host:".length);
}
/**
* The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as
* hosts, `*` left out as the switch beside it shows it. Null when there are none.
*/
export function listSummary(list: DecryptListEntry[]): string | null {
let apps = 0;
let hosts = 0;
for (const e of list) {
if (e.builtIn) continue;
const parsed = parseEntry(e.entry);
if (parsed == null || parsed.negated) continue;
if (parsed.kind === "app") apps++;
if (parsed.kind === "host") hosts++;
}
const parts = [
apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null,
hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null,
].filter((p) => p != null);
return parts.length === 0 ? null : parts.join(", ");
}
/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */
export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean {
return (
edit.add.some((e) => !e.trim().startsWith("!")) ||
edit.remove.some((e) => e.trim().startsWith("!"))
);
}
/** The edit that puts back what `edit` changed, for Undo */
export function inverseEdit(edit: { add: string[]; remove: string[] }): {
add: string[];
remove: string[];
} {
return { add: edit.remove, remove: edit.add };
}
/** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */
export type DecryptSubject =
| { kind: "host"; host: string }
| { kind: "site"; site: string }
| { kind: "app"; name: string; bundleId: string | null };
/** The entry that names `subject` alone: `host:api.example.com`, `host:*.example.com`, `app:Slack` */
export function subjectEntry(subject: DecryptSubject): string {
switch (subject.kind) {
case "host":
return normalizeEntry(`host:${subject.host}`) ?? `host:${subject.host}`;
case "site":
return `host:*.${subject.site.toLowerCase()}`;
case "app":
return `app:${subject.name}`;
}
}
/** The connection a subject stands for, as the list judges it */
export function subjectQuery(subject: DecryptSubject): DecryptQuery {
switch (subject.kind) {
case "host": {
const parsed = parseHost(subject.host);
return { host: parsed?.pattern ?? subject.host, port: parsed?.port ?? 443 };
}
case "site":
return { host: subject.site };
case "app":
// No host matches an empty one, so only `*` and app entries decide.
return { host: "", app: { name: subject.name, bundleId: subject.bundleId } };
}
}
/**
* An exclusion of the user's that matches `q`, which no entry added beats: Decrypt cannot help
* until it is edited. Null when there is none.
*/
export function blockingExclusion(
list: DecryptListEntry[],
q: DecryptQuery,
builtIns = false,
): string | null {
for (const e of list) {
if (e.builtIn && !builtIns) continue;
const parsed = parseEntry(e.entry);
if (parsed?.negated && matches(parsed, q)) return e.entry;
}
return null;
}
/**
* What keeps Decrypt on `subject` from working: an exclusion of the user's that matches it,
* other than its own exact one, which Decrypt removes. Null when Decrypt works.
*/
export function blockerOf(list: DecryptListEntry[], subjects: DecryptSubject[]): string | null {
const own = new Set(subjects.map((s) => `!${subjectEntry(s)}`));
const rest = list.filter((e) => !own.has(e.entry));
for (const subject of subjects) {
const blocker = blockingExclusion(rest, subjectQuery(subject));
if (blocker != null) return blocker;
}
return null;
}
/** What the Not decrypted detail offers for one tunnelled connection */
export interface DetailActions {
/** The connection is decrypted from the next one on already */
listed: boolean;
/** Decrypt Host would decrypt it */
host: boolean;
/** Decrypt App would decrypt it */
app: boolean;
/** Neither would: the exclusion that keeps it out */
blocked: string | null;
}
/**
* Each button judged on its own: whether decrypting the host, or the app, would decrypt this
* connection, with its host, port and app together. A button whose change something else
* still excludes is not offered; with neither, the exclusion is named.
*/
export function detailActions(
list: DecryptListEntry[],
host: string,
app: { name: string; bundleId: string | null } | null,
): DetailActions {
const hostSubject: DecryptSubject = { kind: "host", host };
const q = { ...subjectQuery(hostSubject), app };
if (decrypts(list, q)) return { listed: true, host: false, app: false, blocked: null };
const works = (subject: DecryptSubject) => decrypts(setDecrypted(list, [subject], true), q);
const hostWorks = works(hostSubject);
const appWorks = app != null && works({ kind: "app", ...app });
const blocked =
hostWorks || appWorks ? null : (blockingExclusion(list, q) ?? blockingExclusion(list, q, true));
return { listed: false, host: hostWorks, app: appWorks, blocked };
}
/** The list decrypts `subject`'s connections, as its sidebar row marks it */
export function isDecrypted(list: DecryptListEntry[], subject: DecryptSubject): boolean {
return decrypts(list, subjectQuery(subject));
}
/**
* The entries after Decrypt or Stop Decrypting on `subject`, to save whole. Decrypt drops an
* exclusion of the subject the user added, then adds its entry if it is still not decrypted.
* Stop drops its entry, then excludes it if something broader, `*` say, still decrypts it.
*/
export function toggleDecrypt(list: DecryptListEntry[], subject: DecryptSubject): string[] {
return setDecrypted(list, [subject], !isDecrypted(list, subject)).map((e) => e.entry);
}
/**
* The list with every one of `subjects` decrypted (`on`) or not, each as [`toggleDecrypt`]
* changes it, those already so left alone: several sidebar rows at once.
*/
export function setDecrypted(
list: DecryptListEntry[],
subjects: DecryptSubject[],
on: boolean,
): DecryptListEntry[] {
let next = list;
for (const subject of subjects) {
if (isDecrypted(next, subject) === on) continue;
const entry = subjectEntry(subject);
const drop = on ? `!${entry}` : entry;
next = next.filter((e) => e.builtIn || e.entry !== drop);
if (isDecrypted(next, subject) === on) continue;
// Something the user excluded more broadly still keeps it out; adding would change nothing.
if (on && blockingExclusion(next, subjectQuery(subject)) != null) continue;
const add = on ? entry : `!${entry}`;
next = [...next.filter((e) => !e.builtIn), { entry: add, builtIn: false }, ...builtIns(next)];
}
return next;
}
/**
* The sidebar menu's row for the selected rows that can be decrypted: Stop Decrypting when every
* one of them is, else Decrypt. Null when none can.
*/
export function decryptMenuAction(
list: DecryptListEntry[],
subjects: DecryptSubject[],
): { label: string; on: boolean; subject: string } | null {
if (subjects.length === 0) return null;
const on = !subjects.every((s) => isDecrypted(list, s));
const subject = subjectsName(subjects);
return {
label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`,
on,
subject: subject.text,
};
}
/** What a subject is called: its host, `*.site`, or its app */
export function subjectName(subject: DecryptSubject): string {
switch (subject.kind) {
case "host":
return subject.host;
case "site":
return `*.${subject.site}`;
case "app":
return subject.name;
}
}
/**
* Several subjects named together: one by its name, several of a kind counted, `3 Domains` in a
* menu and `3 domains` in a toast, and mixed kinds as `3 Selected`
*/
function subjectsName(subjects: DecryptSubject[]): { title: string; text: string } {
const [first] = subjects;
if (subjects.length === 1 && first != null) {
const name = subjectName(first);
return { title: name, text: name };
}
const kinds = new Set(subjects.map((s) => s.kind));
const n = subjects.length;
if (kinds.size > 1) return { title: `${n} Selected`, text: `${n} selected` };
const noun = first?.kind === "app" ? "Apps" : first?.kind === "site" ? "Domains" : "Hosts";
return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` };
}
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
return list.filter((e) => e.builtIn);
}
/**
* What turns `before` into `after`, as `edit_decrypt_list` takes it: the entries to add and to
* remove, applied to the list as it is when they land, so edits made together each keep the
* other's.
*/
export function listEdit(
before: DecryptListEntry[],
after: DecryptListEntry[],
): { add: string[]; remove: string[] } {
const was = new Set(before.map((e) => e.entry));
const now = new Set(after.map((e) => e.entry));
return {
add: after.filter((e) => !was.has(e.entry)).map((e) => e.entry),
remove: before.filter((e) => !now.has(e.entry)).map((e) => e.entry),
};
}
/** The edit that adds `*`, or takes it out, as the first-run card's choice makes it */
export function everythingEdit(on: boolean): { add: string[]; remove: string[] } {
return on ? { add: [EVERYTHING], remove: [] } : { add: [], remove: [EVERYTHING] };
}
/** Whether the list decrypts everything, as the first-run card's choice reads it */
export function decryptsEverything(list: DecryptListEntry[]): boolean {
return list.some((e) => e.entry === EVERYTHING);
}
/**
* What the list's input suggests: `host:`, `app:` and `!` to start an entry; after `host:` the
* hosts seen in the capture, after `app:` the apps seen, by name and bundle id.
*/
export function decryptFilterOptions(seen: SeenTargets | null | undefined): FilterOptions {
const apps = new Set<string>();
for (const app of seen?.apps ?? []) {
apps.add(app.name);
if (app.bundleId != null) apps.add(app.bundleId);
}
return {
fieldPrefix: "",
replaceValueOnly: true,
negation: { prefix: "!", info: "Exclude" },
fields: [
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.domain" },
{ name: "app", values: [...apps], info: "App name or bundle ID" },
],
};
}