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(); 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" }, ], }; }