From 77226dbedb0df37d341d465af0e64161abe730a1 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 8 Nov 2026 09:07:04 -0700 Subject: [PATCH] Put Yaak Proxy's search on the shared query engine (#826) Co-authored-by: Claude Fable 5.1 --- Cargo.lock | 1 + apps/yaak-client/components/Sidebar.tsx | 36 +- .../core/Editor/filter/extension.ts | 88 -- apps/yaak-proxy/components/FlowListEmpty.tsx | 4 +- apps/yaak-proxy/components/ProxyLayout.tsx | 2 + .../components/SavedSearchDialog.tsx | 82 ++ apps/yaak-proxy/components/SearchBar.tsx | 128 +- apps/yaak-proxy/components/Sidebar.tsx | 38 +- apps/yaak-proxy/harness/fixtures.ts | 1 + apps/yaak-proxy/harness/streamFixtures.ts | 1 + apps/yaak-proxy/index.html | 3 + apps/yaak-proxy/lib/flowViews.test.ts | 1 + apps/yaak-proxy/lib/query.test.ts | 107 ++ apps/yaak-proxy/lib/query.ts | 147 ++ apps/yaak-proxy/lib/search.test.ts | 287 +--- apps/yaak-proxy/lib/search.ts | 217 +-- apps/yaak-proxy/lib/searchBox.ts | 79 ++ apps/yaak-proxy/lib/searchChanges.test.ts | 96 ++ apps/yaak-proxy/lib/searchChanges.ts | 105 ++ apps/yaak-proxy/lib/sidebarTree.test.ts | 51 +- apps/yaak-proxy/lib/sidebarTree.ts | 87 +- apps/yaak-proxy/lib/store.ts | 159 ++- apps/yaak-proxy/package.json | 1 + apps/yaak-proxy/vite.config.ts | 6 +- crates-proxy/yaak-proxy-lib/Cargo.toml | 2 + .../yaak-proxy-lib/bindings/gen_models.ts | 33 +- .../yaak-proxy-lib/bindings/gen_rpc.ts | 115 +- .../migrations/00000012_search.sql | 13 + crates-proxy/yaak-proxy-lib/src/control.rs | 7 +- crates-proxy/yaak-proxy-lib/src/db.rs | 34 +- crates-proxy/yaak-proxy-lib/src/flows.rs | 598 +++----- crates-proxy/yaak-proxy-lib/src/lib.rs | 209 ++- crates-proxy/yaak-proxy-lib/src/models.rs | 19 + crates-proxy/yaak-proxy-lib/src/query.rs | 1242 +++++++++++++++++ .../yaak-proxy-lib/src/query/tests.rs | 1001 +++++++++++++ .../yaak-proxy-lib/src/saved_queries.rs | 181 +++ crates-proxy/yaak-proxy-lib/tests/at_rest.rs | 20 +- crates-proxy/yaak-proxy-lib/tests/control.rs | 18 +- crates-proxy/yaak-proxy-lib/tests/flow.rs | 4 +- crates-proxy/yaak-proxy-lib/tests/streams.rs | 2 +- crates/yaak-query/bindings/query.ts | 18 +- crates/yaak-query/pkg/yaak_query_bg.wasm | Bin 340382 -> 339850 bytes crates/yaak-query/src/complete.rs | 77 +- crates/yaak-query/src/condition.rs | 241 ++++ crates/yaak-query/src/eval.rs | 201 +-- crates/yaak-query/src/lib.rs | 8 +- crates/yaak-query/src/offsets.rs | 52 + crates/yaak-query/src/schema.rs | 8 +- crates/yaak-query/src/wasm.rs | 32 +- package-lock.json | 1 + .../src/components/editor/filter/extension.ts | 31 +- .../components/editor/filter/filter.grammar | 78 -- .../components/editor/filter/filter.test.ts | 42 - .../ui/src/components/editor/filter/filter.ts | 22 - .../src/components/editor/filter/highlight.ts | 22 - .../components/editor/query/extension.test.ts | 23 + .../src/components/editor/query/extension.ts | 264 ++++ packages/ui/src/editor.ts | 3 +- 58 files changed, 4754 insertions(+), 1594 deletions(-) delete mode 100644 apps/yaak-client/components/core/Editor/filter/extension.ts create mode 100644 apps/yaak-proxy/components/SavedSearchDialog.tsx create mode 100644 apps/yaak-proxy/lib/query.test.ts create mode 100644 apps/yaak-proxy/lib/query.ts create mode 100644 apps/yaak-proxy/lib/searchBox.ts create mode 100644 apps/yaak-proxy/lib/searchChanges.test.ts create mode 100644 apps/yaak-proxy/lib/searchChanges.ts create mode 100644 crates-proxy/yaak-proxy-lib/migrations/00000012_search.sql create mode 100644 crates-proxy/yaak-proxy-lib/src/query.rs create mode 100644 crates-proxy/yaak-proxy-lib/src/query/tests.rs create mode 100644 crates-proxy/yaak-proxy-lib/src/saved_queries.rs create mode 100644 crates/yaak-query/src/condition.rs create mode 100644 crates/yaak-query/src/offsets.rs delete mode 100644 packages/ui/src/components/editor/filter/filter.grammar delete mode 100644 packages/ui/src/components/editor/filter/filter.test.ts delete mode 100644 packages/ui/src/components/editor/filter/filter.ts delete mode 100644 packages/ui/src/components/editor/filter/highlight.ts create mode 100644 packages/ui/src/components/editor/query/extension.test.ts create mode 100644 packages/ui/src/components/editor/query/extension.ts diff --git a/Cargo.lock b/Cargo.lock index 90c0664ee..73bdd8026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11965,6 +11965,7 @@ dependencies = [ "yaak-models", "yaak-proxy", "yaak-proxy-helper", + "yaak-query", "yaak-rpc", "yaak-templates", ] diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index cb3044736..0a94f5426 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -65,7 +65,7 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import { ContextMenu, Dropdown } from "./core/Dropdown"; import type { Node as QueryNode, Schema } from "@yaakapp-internal/query"; import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query"; -import { queryFilter } from "./core/Editor/filter/extension"; +import { queryFilter } from "@yaakapp-internal/ui/editor"; import { HttpMethodTag } from "./core/HttpMethodTag"; import { Icon, @@ -850,18 +850,27 @@ function setSidebarFilterText(text: string) { jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` }); } -/** The fields `getItemFields` fills in. Bare words match `getItemText` by word prefix */ -const sidebarFilterColumns = [ - "method", - "url", - "name", - "type", - "folder", - "grpc_service", - "grpc_method", +/** + * The fields `getItemFields` fills in, each with what it is and a term using it, as completion + * shows them. Bare words match `getItemText` by word prefix + */ +const sidebarFilterFields: [name: string, info: string, example: string][] = [ + ["method", "GET, POST, …", "@method:post"], + ["url", "Request URL", "@url:*/users*"], + ["name", "Request name", '@name:"Get user"'], + ["type", "http, grpc or ws", "@type:grpc"], + ["folder", "Parent folder", "@folder:auth"], + ["grpc_service", "gRPC service", "@grpc_service:*Greeter"], + ["grpc_method", "gRPC method", "@grpc_method:SayHello"], ]; +const sidebarFilterColumns = sidebarFilterFields.map(([name]) => name); const sidebarFilterSchema: Schema = { - fields: sidebarFilterColumns.map((name) => ({ name, kind: { type: "text" } })), + fields: sidebarFilterFields.map(([name, info, example]) => ({ + name, + kind: { type: "text" }, + info, + example, + })), freeText: "fuzzy", }; @@ -1005,9 +1014,12 @@ const sidebarTreeAtom = atom< matcher.free(); markHidden(root); + // Values offered after `@field:`. URLs and names are left out: nearly every one is unique, + // so a list of them is noise rather than a shortcut. const fieldValues: Record = {}; for (const [name, values] of Object.entries(allFields)) { - fieldValues[name] = Array.from(values).filter((v) => v.length < 20); + if (name === "url" || name === "name") continue; + fieldValues[name] = Array.from(values); } const suggestions = Array.from(suggestionFields) .sort((a, b) => { diff --git a/apps/yaak-client/components/core/Editor/filter/extension.ts b/apps/yaak-client/components/core/Editor/filter/extension.ts deleted file mode 100644 index 003729fe5..000000000 --- a/apps/yaak-client/components/core/Editor/filter/extension.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"; -import { autocompletion, startCompletion } from "@codemirror/autocomplete"; -import { highlightingFor } from "@codemirror/language"; -import type { EditorState, Extension } from "@codemirror/state"; -import { RangeSetBuilder } from "@codemirror/state"; -import type { DecorationSet, ViewUpdate } from "@codemirror/view"; -import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; -import type { Tag } from "@lezer/highlight"; -import { tags as t } from "@lezer/highlight"; -import type { Schema, TokenKind } from "@yaakapp-internal/query"; -import { queryCompletions, queryTokens } from "@yaakapp-internal/query"; - -export interface QueryFilterOptions { - schema: Schema; - /** The values seen for each field path, offered after `@field:` */ - values?: Record; -} - -const tokenTags: Record = { - field: t.attributeName, - value: t.attributeValue, - operator: t.operatorKeyword, - quote: t.string, - paren: t.paren, - negation: t.operator, - text: null, -}; - -function buildDecorations(state: EditorState): DecorationSet { - const builder = new RangeSetBuilder(); - for (const token of queryTokens(state.doc.toString())) { - const tag = tokenTags[token.kind]; - const className = tag == null ? null : highlightingFor(state, [tag]); - if (className == null || token.span.start === token.span.end) continue; - builder.add(token.span.start, token.span.end, Decoration.mark({ class: className })); - } - return builder.finish(); -} - -const highlight = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = buildDecorations(view.state); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildDecorations(update.state); - } - } - }, - { decorations: (v) => v.decorations }, -); - -function completionSource({ schema, values }: QueryFilterOptions) { - return (ctx: CompletionContext): CompletionResult | null => { - const result = queryCompletions(ctx.state.doc.toString(), ctx.pos, schema, values); - if (result == null) return null; - - const { start, end } = result.span; - // Field names pop up for something typed; on an empty spot, only when asked for - const onlyFields = result.items.every((item) => item.kind === "field"); - if (onlyFields && start === end && !ctx.explicit) return null; - - const options: Completion[] = result.items.map((item) => ({ - label: item.label, - type: item.kind === "field" ? "property" : "constant", - info: item.info ?? undefined, - apply: (view, _completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: item.insert }, - selection: { anchor: from + item.insert.length }, - }); - if (item.reopen) startCompletion(view); - }, - })); - - // The engine has already filtered and ranked the options - return { from: start, to: end, options, filter: false }; - }; -} - -/** Highlighting and completion for a search box written in Yaak's query language */ -export function queryFilter(options: QueryFilterOptions): Extension { - return [highlight, autocompletion({ override: [completionSource(options)] })]; -} diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx index 5c3b28a8e..6c51e596a 100644 --- a/apps/yaak-proxy/components/FlowListEmpty.tsx +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import { useAtomValue, useSetAtom } from "jotai"; import { useEffect, useState } from "react"; import { rpc } from "../lib/rpc"; -import { parseSearch, widerQueries } from "../lib/search"; +import { activeQuery, widerQueries } from "../lib/query"; import { hideSystemAtom, pausedAtAtom, @@ -77,7 +77,7 @@ function NoResults({ text }: { text: string }) { const candidates = widerQueries(text); void Promise.all( candidates.map(async (candidate) => { - const query = parseSearch(candidate, hideSystem); + const query = activeQuery(candidate, hideSystem); const res = await rpc("list_flows", { ...query, limit: 0 }); return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) }; }), diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 18601c197..95a4b181b 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -22,6 +22,7 @@ import { LockedState } from "./LockedState"; import { ProxyStatus, useProxySetup } from "./ProxyStatus"; import { HelperDialog } from "./HelperDialog"; import { SaveDialog } from "./SaveDialog"; +import { SavedSearchDialog } from "./SavedSearchDialog"; import { SearchBar } from "./SearchBar"; import { SettingsWindow, useOpenSettings } from "./Settings"; import { Sidebar } from "./Sidebar"; @@ -158,6 +159,7 @@ export function ProxyLayout() { + diff --git a/apps/yaak-proxy/components/SavedSearchDialog.tsx b/apps/yaak-proxy/components/SavedSearchDialog.tsx new file mode 100644 index 000000000..6fda7826f --- /dev/null +++ b/apps/yaak-proxy/components/SavedSearchDialog.tsx @@ -0,0 +1,82 @@ +import { Dialog, DialogFooter, PlainInput } from "@yaakapp-internal/ui"; +import { atom, useAtom } from "jotai"; +import { useState } from "react"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useShowToast } from "../lib/toast"; + +/** Saving the search box's text under a name, or renaming a saved search */ +export type SavedSearchDialog = + | { mode: "save"; query: string } + | { mode: "rename"; id: string; name: string }; + +export const savedSearchDialogAtom = atom(null); + +const FORM_ID = "saved-search"; + +/** Names a saved search, as `savedSearchDialogAtom` asks */ +export function SavedSearchDialog() { + const [dialog, setDialog] = useAtom(savedSearchDialogAtom); + if (dialog == null) return null; + // Keyed, so each opening starts from its own name. + return ( + setDialog(null)} + /> + ); +} + +function NameDialog({ dialog, onClose }: { dialog: SavedSearchDialog; onClose: () => void }) { + const [name, setName] = useState(dialog.mode === "rename" ? dialog.name : ""); + const showToast = useShowToast(); + const onError = (err: Error) => + showToast({ id: "saved-search", message: err.message, color: "danger" }); + const create = useRpcMutation("create_saved_query", { onSuccess: onClose, onError }); + const rename = useRpcMutation("rename_saved_query", { onSuccess: onClose, onError }); + const busy = create.isPending || rename.isPending; + + const submit = () => { + if (name.trim() === "" || busy) return; + if (dialog.mode === "save") create.mutate({ name, query: dialog.query }); + else rename.mutate({ id: dialog.id, name }); + }; + + return ( + +
{ + e.preventDefault(); + submit(); + }} + > + + + +
+ ); +} diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx index 44d1cc045..cdcbf7271 100644 --- a/apps/yaak-proxy/components/SearchBar.tsx +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -1,34 +1,53 @@ -import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui"; +import { startCompletion } from "@codemirror/autocomplete"; +import type { Extension } from "@codemirror/state"; +import { Compartment } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { + Dropdown, + Icon, + IconButton, + Input, + type InputHandle, + LazyCodeEditor, +} from "@yaakapp-internal/ui"; +import { queryFilter } from "@yaakapp-internal/ui/editor"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; -import { memo, type RefObject, useEffect, useRef } from "react"; +import { memo, useEffect, useMemo, useRef } from "react"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox"; import { allTrafficTotalAtom, lockedAtom, + savedQueriesAtom, searchTermsAtom, searchTextAtom, searchTextVersionAtom, setSearchTextAtom, } from "../lib/store"; +import { savedSearchDialogAtom } from "./SavedSearchDialog"; /** How long typing settles before the search runs */ const SEARCH_DEBOUNCE_MS = 200; /** - * The header's search over every flow in the database, by URL, method, status and content type, - * and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows. - * ⌘F focuses it and Escape clears it. + * The header's search over every flow in the database, in the query language: bare words match + * the URL, method, status and content type, and `@field:value` terms the rest, highlighted and + * completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs + * it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape + * clears it. */ export const SearchBar = memo(function SearchBar() { const [text, setText] = useAtom(searchTextAtom); const setTerms = useSetAtom(searchTermsAtom); const setSearchText = useSetAtom(setSearchTextAtom); const version = useAtomValue(searchTextVersionAtom); + const total = useAtomValue(allTrafficTotalAtom); + const store = useStore(); + const setSavedDialog = useSetAtom(savedSearchDialogAtom); // Nothing to search while captured traffic is locked. const locked = useAtomValue(lockedAtom) != null; - // Read, not subscribed: `PlaceholderCount` keeps it current without re-rendering the field. - const total = useStore().get(allTrafficTotalAtom); - // PlainInput hands back its as the ref - const inputRef = useRef(null); + const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema; + const handleRef = useRef(null); useEffect(() => { const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS); @@ -39,43 +58,69 @@ export const SearchBar = memo(function SearchBar() { const onKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") { e.preventDefault(); - inputRef.current?.focus(); - inputRef.current?.select(); + handleRef.current?.selectAll(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); + // The language goes in once the schema is read: into the editor there is, through a + // compartment, and into each one made after, as taking text from the sidebar makes one. + const compartment = useRef(new Compartment()); + const filter = useMemo( + () => + schema == null + ? [] + : queryFilter({ + schema, + loadValues: loadSeenValues, + // Read as completion opens, so saved searches are current without remaking it. + moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed), + problems: searchProblems, + }), + [schema, store], + ); + const extensions = useMemo(() => compartment.current.of(filter), [filter]); + useEffect(() => { + handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) }); + }, [filter]); + // The field is replaced to take a value from outside, so focus moves to the new one. const clear = () => { setSearchText(""); - requestAnimationFrame(() => inputRef.current?.focus()); + requestAnimationFrame(() => handleRef.current?.focus()); }; return ( - ( + + )} + setRef={(h) => { + handleRef.current = h; + }} label="Search flows" hideLabel size="sm" className="font-mono text-xs" + stateKey={null} + language={null} + wrapLines={false} placeholder={locked ? "Traffic is locked" : placeholderFor(total)} disabled={locked} defaultValue={text} forceUpdateKey={String(version)} onChange={setText} - onKeyDownCapture={(e) => { - if (e.key === "Escape" && text !== "") { + extensions={extensions} + onKeyDown={(e) => { + if (e.key === "Escape" && handleRef.current?.value() !== "") { e.stopPropagation(); clear(); } }} leftSlot={ - <> - - - + } rightSlot={ text === "" ? ( @@ -83,14 +128,26 @@ export const SearchBar = memo(function SearchBar() { ⌘F ) : ( - +
+ , + onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }), + }, + ]} + > + + + +
) } /> @@ -98,15 +155,14 @@ export const SearchBar = memo(function SearchBar() { }); /** - * Keeps the placeholder's count current as flows arrive, alone, so new traffic re-renders this - * and not the whole field + * Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in + * Yaak's editors, whose shared editor leaves the key to each app */ -function PlaceholderCount({ inputRef }: { inputRef: RefObject }) { - const total = useAtomValue(allTrafficTotalAtom); - useEffect(() => { - if (inputRef.current != null) inputRef.current.placeholder = placeholderFor(total); - }, [inputRef, total]); - return null; +function startCompletionOnCtrlSpace(e: KeyboardEvent, view: EditorView): boolean { + if (!(e.ctrlKey && e.key === " " && !e.metaKey && !e.altKey && !e.shiftKey)) return false; + e.preventDefault(); + startCompletion(view); + return true; } /** "Search 4,102 flows", the title and count that sat in the header's middle */ diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 5bea89e8a..23f3be64e 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -7,10 +7,11 @@ import { type TreeProps, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { atom, useAtom, useAtomValue } from "jotai"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { useCallback, useEffect } from "react"; import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; +import { useRpcMutation } from "../hooks/useRpcMutation"; import { SIDEBAR_TREE_ID } from "../lib/atoms"; import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; @@ -18,6 +19,7 @@ import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; +import { savedSearchDialogAtom } from "./SavedSearchDialog"; /** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */ const expandedAtom = atomWithStorage>("sidebar_expanded", {}, undefined, { @@ -63,6 +65,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.kind === "all" && } {item.kind === "tunnelled" && } + {item.kind === "saved" && } {item.kind === "folder" && } {item.appPath != null && } {item.label} @@ -119,17 +122,46 @@ function HideSystemToggle() { export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); const getItemKey = useCallback( - (item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`, + // The label too, as a saved search's changes when it is renamed + (item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}:${item.label}`, [], ); const decryptList = useDecryptList(); const setDecryptList = useEditDecryptList(); const { mutate: saveDecryptList } = setDecryptList; const showToast = useShowToast(); + const setSavedDialog = useSetAtom(savedSearchDialogAtom); + const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", { + onError: (err) => showToast({ id: "saved-search", message: err.message, color: "danger" }), + }); // Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on. + // A saved search is renamed or deleted. const getContextMenu = useCallback( (items: SidebarItem[]): DropdownItem[] => { + const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); + if (saved.length > 0) { + const [first] = saved; + return [ + { + label: "Rename", + leftSlot: , + hidden: saved.length > 1 || first == null, + onSelect: () => { + if (first != null) + setSavedDialog({ mode: "rename", id: first.id, name: first.label }); + }, + }, + { + label: "Delete", + color: "danger", + leftSlot: , + onSelect: () => { + for (const s of saved) deleteSaved({ id: s.id }); + }, + }, + ]; + } const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt])); const action = decryptMenuAction(decryptList, subjects); if (action == null) return []; @@ -155,7 +187,7 @@ export function Sidebar() { }, ]; }, - [decryptList, saveDecryptList, showToast], + [decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved], ); const renderContextMenu = useCallback["renderContextMenu"]>>( ({ items, position, onClose }) => diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts index 2f052dd74..cc90cf443 100644 --- a/apps/yaak-proxy/harness/fixtures.ts +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -51,6 +51,7 @@ const base: Flow = { grpcStatus: null, grpcMessage: null, tunnelled: false, + agentRead: false, clientAddr: "127.0.0.1:54321", elapsedConnectMs: 18, elapsedTlsMs: 41, diff --git a/apps/yaak-proxy/harness/streamFixtures.ts b/apps/yaak-proxy/harness/streamFixtures.ts index 9fcbe3c9a..47fa878b7 100644 --- a/apps/yaak-proxy/harness/streamFixtures.ts +++ b/apps/yaak-proxy/harness/streamFixtures.ts @@ -50,6 +50,7 @@ const flowBase: Flow = { grpcStatus: null, grpcMessage: null, tunnelled: false, + agentRead: false, clientAddr: null, elapsedConnectMs: null, elapsedTlsMs: null, diff --git a/apps/yaak-proxy/index.html b/apps/yaak-proxy/index.html index 60d859012..cc49b8e4a 100644 --- a/apps/yaak-proxy/index.html +++ b/apps/yaak-proxy/index.html @@ -24,5 +24,8 @@ +
+
+
diff --git a/apps/yaak-proxy/lib/flowViews.test.ts b/apps/yaak-proxy/lib/flowViews.test.ts index 5c7daff3c..6ef8d323c 100644 --- a/apps/yaak-proxy/lib/flowViews.test.ts +++ b/apps/yaak-proxy/lib/flowViews.test.ts @@ -59,6 +59,7 @@ const flow: Flow = { grpcStatus: null, grpcMessage: null, tunnelled: false, + agentRead: false, clientAddr: null, elapsedConnectMs: null, elapsedTlsMs: null, diff --git a/apps/yaak-proxy/lib/query.test.ts b/apps/yaak-proxy/lib/query.test.ts new file mode 100644 index 000000000..dea324fbf --- /dev/null +++ b/apps/yaak-proxy/lib/query.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + activeQuery, + anyCountsBackFromNow, + asOneTerm, + countsBackFromNow, + fieldTerm, + pathTerm, + termKeys, + widerQueries, +} from "./query"; +import { savedCompletions } from "./searchBox"; + +describe("terms", () => { + test("quote values that need it", () => { + expect(fieldTerm("app", "Google Chrome")).toBe('@app:"Google Chrome"'); + expect(fieldTerm("host", "api.x.com")).toBe("@host:api.x.com"); + expect(fieldTerm("is", "system", true)).toBe("-@is:system"); + }); + + test("select a path and what is under it", () => { + expect(pathTerm("/v1/charges")).toBe("@path:/v1/charges/*"); + expect(pathTerm("/a b")).toBe('@path:"/a b"'); + }); + + test("are read back as written, any case, with their exclusion", () => { + expect(termKeys('@app:"Google Chrome" -@is:System (a OR -b)')).toEqual([ + '@app:"google chrome"', + "-@is:system", + "a", + "-b", + ]); + expect(termKeys("")).toEqual([]); + }); +}); + +describe("activeQuery", () => { + test("is null for all traffic, and carries hiding System", () => { + expect(activeQuery(" ", false)).toBeNull(); + expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true }); + expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false }); + }); +}); + +describe("widerQueries", () => { + test("leaves out each term in turn", () => { + expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([ + '@status:5xx "card declined"', + '@app:Slack "card declined"', + "@app:Slack @status:5xx", + ]); + expect(widerQueries("@app:Slack")).toEqual([]); + }); + + test("widens one OR group at a time, keeping the groups", () => { + const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com"; + expect(widerQueries(text)).toEqual([ + "@host:absent.com OR @app:Chrome @host:absent.com", + "@app:Slack OR @app:Chrome @host:absent.com", + "@app:Slack @host:absent.com OR @host:absent.com", + "@app:Slack @host:absent.com OR @app:Chrome", + ]); + // A group of one term is not emptied, which would take in every flow. + expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([ + "@app:Slack OR @host:b.com", + "@app:Slack OR @app:Chrome", + ]); + }); + + test("keeps groups and exclusions whole", () => { + expect(widerQueries("(@status:4xx OR @status:5xx) -(a OR b) c")).toEqual([ + "-(a OR b) c", + "(@status:4xx OR @status:5xx) c", + "(@status:4xx OR @status:5xx) -(a OR b)", + ]); + }); +}); + +describe("countsBackFromNow", () => { + test("is a @time comparison or range, which ages", () => { + expect(countsBackFromNow("@time:<5m")).toBe(true); + expect(countsBackFromNow("x -(@time:[1h TO 2h] OR y)")).toBe(true); + expect(countsBackFromNow("@time:2026-11-07")).toBe(false); + expect(countsBackFromNow("@status:<500 time")).toBe(false); + }); + + test("of saved searches, tells when their counts change with time alone", () => { + expect(anyCountsBackFromNow(["@status:4xx", "-@time:>1h stripe"])).toBe(true); + expect(anyCountsBackFromNow(["@status:4xx", "@time:2026-11-07"])).toBe(false); + expect(anyCountsBackFromNow([])).toBe(false); + }); +}); + +describe("saved searches as completions", () => { + test("keep an OR at their top in one group, however it is spaced", () => { + expect(asOneTerm("@status:4xx OR @status:5xx")).toBe("(@status:4xx OR @status:5xx)"); + expect(asOneTerm("(@status:4xx)OR(@status:5xx)")).toBe("((@status:4xx)OR(@status:5xx))"); + expect(asOneTerm("(a OR b) c")).toBe("(a OR b) c"); + const saved = [ + { id: "1", name: "Errors", query: "(@status:4xx)OR(@status:5xx)", sortOrder: 0 }, + { id: "2", name: "Stripe", query: "@host:*.stripe.com", sortOrder: 1 }, + ]; + expect(savedCompletions(saved, "err").map((c) => c.apply)).toEqual([ + "((@status:4xx)OR(@status:5xx))", + ]); + }); +}); diff --git a/apps/yaak-proxy/lib/query.ts b/apps/yaak-proxy/lib/query.ts new file mode 100644 index 000000000..c0eb4021b --- /dev/null +++ b/apps/yaak-proxy/lib/query.ts @@ -0,0 +1,147 @@ +import type { Node } from "@yaakapp-internal/query"; +import { formatField, parseQuery } from "@yaakapp-internal/query"; + +/** + * A search over every flow in the database, as `list_flows` takes it: the search box's text, + * which the backend parses and runs, and whether System's flows are hidden. + */ +export interface FlowQuery { + query: string; + hideSystem: boolean; +} + +/** The search the list shows, or null for all traffic */ +export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null { + if (text.trim() === "" && !hideSystem) return null; + return { query: text.trim(), hideSystem }; +} + +/** `@field:value`, or `-@field:value`, the value quoted when it needs to be */ +export function fieldTerm(field: string, value: string, negated = false): string { + return formatField(field, value, negated); +} + +/** A site, its domain and every host below it: `@host:*.github.com` */ +export function siteTerm(site: string): string { + const term = formatField("host", site); + return term.endsWith('"') ? term : `@host:*.${site}`; +} + +/** + * A path and everything under it, `@path:/v1/charges/*`, as a sidebar row selects it. A path + * that has to be quoted cannot carry the wildcard, so it matches as written. + */ +export function pathTerm(path: string): string { + const term = formatField("path", path); + if (term.endsWith('"')) return term; + return `${term.replace(/\/+$/, "")}/*`; +} + +/** `node` written back as query text, from its source, groups in parentheses where they need */ +export function nodeText(node: Node, source: string, nested = false): string { + switch (node.type) { + case "And": { + const text = node.children.map((c) => nodeText(c, source, true)).join(" "); + return nested ? `(${text})` : text; + } + case "Or": { + const text = node.children.map((c) => nodeText(c, source, true)).join(" OR "); + return nested ? `(${text})` : text; + } + case "Not": + return `-${nodeText(node.node, source, true)}`; + default: + return source.slice(node.span.start, node.span.end); + } +} + +/** The terms of a query, each as written and with a `-` when it is excluded, lowercased */ +export function termKeys(text: string): string[] { + const keys: string[] = []; + const walk = (node: Node, negated: boolean) => { + switch (node.type) { + case "And": + case "Or": + for (const c of node.children) walk(c, negated); + break; + case "Not": + walk(node.node, !negated); + break; + default: + keys.push( + `${negated ? "-" : ""}${text.slice(node.span.start, node.span.end).toLowerCase()}`, + ); + } + }; + const root = parseQuery(text); + if (root != null) walk(root, false); + return keys; +} + +/** + * Wider searches for one that matched nothing: the text with each of its terms left out in + * turn, without repeats. With groups `OR` separates, a term is left out of one group at a time + * and the groups kept, since each group alone is narrower still; a group left with nothing would + * take in every flow, so it is not offered. + */ +export function widerQueries(text: string): string[] { + const root = parseQuery(text); + if (root == null) return []; + const clauses = root.type === "Or" ? root.children : [root]; + const termsOf = (clause: Node) => (clause.type === "And" ? clause.children : [clause]); + const write = (terms: Node[]) => terms.map((t) => nodeText(t, text, true)).join(" "); + const out = new Set(); + clauses.forEach((clause, i) => { + const terms = termsOf(clause); + if (terms.length <= 1) return; + terms.forEach((_, j) => { + const wider = clauses.map((c, k) => + k === i ? write(terms.filter((_, l) => l !== j)) : write(termsOf(c)), + ); + out.add(wider.join(" OR ")); + }); + }); + return [...out]; +} + +/** + * The search has a `@time` term that counts back from now, `@time:<5m`, so which flows it covers + * changes as time passes, not only as flows are written + */ +export function countsBackFromNow(text: string): boolean { + let found = false; + const walk = (node: Node) => { + switch (node.type) { + case "And": + case "Or": + for (const c of node.children) walk(c); + break; + case "Not": + walk(node.node); + break; + case "Field": + if ( + node.path.length === 1 && + node.path[0]?.toLowerCase() === "time" && + (node.value.type === "compare" || node.value.type === "range") + ) { + found = true; + } + break; + default: + } + }; + const root = parseQuery(text); + if (root != null) walk(root); + return found; +} + +/** Any of the searches counts back from now, so its count changes as time passes */ +export function anyCountsBackFromNow(queries: string[]): boolean { + return queries.some(countsBackFromNow); +} + +/** `text` to sit beside other terms as one: in parentheses when it is an `OR` at its top */ +export function asOneTerm(text: string): string { + return parseQuery(text)?.type === "Or" ? `(${text})` : text; +} diff --git a/apps/yaak-proxy/lib/search.test.ts b/apps/yaak-proxy/lib/search.test.ts index 55c838f53..66f930494 100644 --- a/apps/yaak-proxy/lib/search.test.ts +++ b/apps/yaak-proxy/lib/search.test.ts @@ -1,144 +1,6 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import { describe, expect, test } from "vite-plus/test"; -import { - appKeyOf, - formatFieldFilter, - isSystemFlow, - matchesQuery, - parseSearch, - sidebarPath, - siteOf, - tokenize, - widerQueries, -} from "./search"; - -function flow(url: string, extra: Partial = {}): Flow { - return { - url, - method: "GET", - status: 200, - contentType: null, - clientBundleId: null, - clientPath: null, - ...extra, - } as Flow; -} - -// The same flows and terms as `search_matches_url_method_status_and_content_type` in -// `flows.rs`, so the live matcher and the database agree. -const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", { - method: "POST", - contentType: "application/json; charset=utf-8", -}); -const github = flow("https://api.github.com/repos/yaak", { - status: 404, - contentType: "application/json; charset=utf-8", -}); -const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" }); -const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 }); -const all = [stripe, github, logo, health]; - -const search = (q: string) => all.filter((f) => matchesQuery(f, { q })); - -describe("matchesQuery", () => { - test("matches the host, the path and the query, any case", () => { - expect(search("STRIPE")).toEqual([stripe]); - expect(search("/repos/")).toEqual([github]); - expect(search("cus_9")).toEqual([stripe]); - }); - - test("matches the whole method", () => { - expect(search("head")).toEqual([health]); - expect(search("ead")).toEqual([]); - }); - - test("matches a status, its start, or its class", () => { - expect(search("404")).toEqual([github]); - expect(search("50")).toEqual([health]); - expect(search("2xx")).toEqual([stripe, logo]); - }); - - test("matches the content type", () => { - expect(search("png")).toEqual([logo]); - expect(search("json")).toEqual([stripe, github]); - }); - - test("needs every term to match", () => { - expect(search("json get")).toEqual([github]); - expect(search(" json 2xx ")).toEqual([stripe]); - expect(search("json 5xx")).toEqual([]); - expect(search("")).toEqual(all); - }); - - test("keeps flows under a host or path, as the sidebar places them", () => { - const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] }); - expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true); - expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true); - // Another port is a host node of its own, as in the sidebar. - expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false); - // Empty segments and default ports are left out, as the sidebar leaves them. - expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true); - expect(under("x.com/a", "http://x.com:80/a")).toBe(true); - expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false); - expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe( - true, - ); - expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false); - expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false); - // The sidebar lowercases hosts; paths keep their case. - expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true); - expect(under("example.com/a", "http://example.com/A")).toBe(false); - }); - - test("keeps flows from an app, by bundle id or path", () => { - const chrome = flow("https://example.com/", { - clientBundleId: "com.google.Chrome", - clientPath: "/Applications/Google Chrome.app", - }); - const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" }); - const unknown = flow("https://example.com/"); - const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app })); - expect(from(["com.google.Chrome"])).toEqual([chrome]); - expect(from(["/usr/bin/curl"])).toEqual([curl]); - expect(from(["/Applications/Google Chrome.app"])).toEqual([]); - }); - - test("keeps flows from an app by name, or a program by the folder it ran in", () => { - // The flows `filters_by_app_name_folder_site_and_system` in `flows.rs` checks. - const slack = flow("https://edgeapi.slack.com/cache", { - clientName: "Slack", - clientBundleId: "com.tinyspeck.slackmacgap", - clientPath: "/Applications/Slack.app", - }); - const node = flow("https://api.github.com:443/repos", { - clientName: "node", - clientPath: "/opt/homebrew/bin/node", - clientCwd: "/Users/me/src/fieldwork", - }); - const daemon = flow("https://github.com/login", { - clientName: "nsurlsessiond", - clientPath: "/usr/libexec/nsurlsessiond", - clientCwd: "/", - }); - const none = flow("http://notgithub.com/"); - const flows = [slack, node, daemon, none]; - const where = (query: Parameters[1]) => - flows.filter((f) => matchesQuery(f, query)); - expect(where({ app: ["slack"] })).toEqual([slack]); - expect(where({ app: ["Fieldwork"] })).toEqual([node]); - expect(where({ app: ["/opt/homebrew/bin/node"] })).toEqual([node]); - expect(where({ under: ["*.github.com"] })).toEqual([node, daemon]); - expect(where({ under: ["api.github.com"] })).toEqual([node]); - expect(where({ system: true })).toEqual([daemon]); - expect(where({ system: false })).toEqual([slack, node, none]); - expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]); - }); - - test("combines terms with the sidebar's selection", () => { - expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true); - expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false); - }); -}); +import { appKeyOf, isSystemFlow, sidebarPath, siteOf } from "./search"; describe("sidebarPath", () => { // The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree. @@ -159,125 +21,6 @@ describe("sidebarPath", () => { }); }); -describe("parseSearch", () => { - test("is null for text that narrows nothing", () => { - expect(parseSearch("")).toBeNull(); - expect(parseSearch(" ")).toBeNull(); - expect(parseSearch("OR")).toBeNull(); - }); - - test("keeps free words as terms, trimmed and collapsed", () => { - expect(parseSearch(" stripe post ")).toEqual({ q: "stripe post" }); - }); - - test("reads apps, hosts and the paths below them", () => { - expect(parseSearch('@app:"Google Chrome" @app:Slack card')).toEqual({ - q: "card", - app: ["Google Chrome", "Slack"], - }); - expect(parseSearch("@app:fieldwork @host:api.stripe.com @path:/v1/payment_intents")).toEqual({ - app: ["fieldwork"], - under: ["api.stripe.com/v1/payment_intents"], - }); - expect(parseSearch("@host:*.github.com")).toEqual({ under: ["*.github.com"] }); - expect(parseSearch("@host:github.com @path:/mountain-loop/yaak/*")).toEqual({ - under: ["github.com/mountain-loop/yaak"], - }); - // A path with no host matches anywhere in the URL. - expect(parseSearch("@path:/v1")).toEqual({ q: "/v1" }); - // Each path stays with the host before it. - expect(parseSearch("@host:a.com @path:/x @host:b.com @path:/y")).toEqual({ - under: ["a.com/x", "b.com/y"], - }); - }); - - test("keeps each clause OR separates as a group of its own", () => { - // Two selected sidebar rows under different apps: Slack on a.com, or Chrome on b.com. - const text = "@app:Slack @host:a.com OR @app:Chrome @host:b.com"; - const query = parseSearch(text); - expect(query).toEqual({ - any: [ - { app: ["Slack"], under: ["a.com"] }, - { app: ["Chrome"], under: ["b.com"] }, - ], - }); - const on = (clientName: string, host: string) => - flow(`https://${host}/x`, { clientName, clientBundleId: `app.${clientName}` }); - const match = (f: Flow) => query != null && matchesQuery(f, query); - expect(match(on("Slack", "a.com"))).toBe(true); - expect(match(on("Chrome", "b.com"))).toBe(true); - // Not every pairing of the two rows' apps and hosts. - expect(match(on("Slack", "b.com"))).toBe(false); - expect(match(on("Chrome", "a.com"))).toBe(false); - - // Words bind to their clause, and hiding System applies to each group that does not ask. - expect(parseSearch("@host:a.com OR @is:system @app:trustd", true)).toEqual({ - any: [ - { under: ["a.com"], system: false }, - { app: ["trustd"], system: true }, - ], - }); - // A clause that narrows nothing takes in every flow. - expect(parseSearch("@app:Slack OR")).toEqual({ app: ["Slack"] }); - expect(parseSearch("@app:Slack OR -@foo:bar")).toBeNull(); - }); - - test("keeps or drops System, which hiding it drops unless the text asks", () => { - expect(parseSearch("@is:system")).toEqual({ system: true }); - expect(parseSearch("-@is:system stripe")).toEqual({ q: "stripe", system: false }); - expect(parseSearch("", true)).toEqual({ system: false }); - expect(parseSearch("@is:system", true)).toEqual({ system: true }); - }); - - test("keeps or drops HTTPS passed through undecrypted", () => { - expect(parseSearch("@is:tunnelled")).toEqual({ tunnelled: true }); - expect(parseSearch("-@is:tunnelled stripe")).toEqual({ q: "stripe", tunnelled: false }); - const tunnel = { url: "https://a.com", method: "CONNECT", tunnelled: true } as Flow; - const decrypted = { url: "https://a.com/x", method: "GET", tunnelled: false } as Flow; - expect(matchesQuery(tunnel, { tunnelled: true })).toBe(true); - expect(matchesQuery(decrypted, { tunnelled: true })).toBe(false); - expect(matchesQuery(decrypted, { tunnelled: false })).toBe(true); - }); - - test("round-trips what the sidebar writes", () => { - const text = [ - formatFieldFilter("app", "Google Chrome"), - formatFieldFilter("host", "api.x.com"), - ].join(" "); - expect(text).toBe('@app:"Google Chrome" @host:api.x.com'); - expect(tokenize(text)).toEqual([ - { field: "app", value: "Google Chrome", negated: false }, - { field: "host", value: "api.x.com", negated: false }, - ]); - }); -}); - -describe("widerQueries", () => { - test("leaves out each term in turn", () => { - expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([ - '@status:5xx "card declined"', - '@app:Slack "card declined"', - "@app:Slack @status:5xx", - ]); - expect(widerQueries("@app:Slack")).toEqual([]); - }); - - test("widens one OR group at a time, keeping the groups", () => { - const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com"; - expect(widerQueries(text)).toEqual([ - "@host:absent.com OR @app:Chrome @host:absent.com", - "@app:Slack OR @app:Chrome @host:absent.com", - "@app:Slack @host:absent.com OR @host:absent.com", - "@app:Slack @host:absent.com OR @app:Chrome", - ]); - // A group of one term is not emptied, which would take in every flow. - expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([ - "@app:Slack OR @host:b.com", - "@app:Slack OR @app:Chrome", - ]); - }); -}); - describe("isSystemFlow", () => { // The cases `tells_system_daemons_apart` in `flows.rs` checks, so the two agree. test("is a daemon outside an app, under the system's folders or by name", () => { @@ -307,3 +50,31 @@ describe("siteOf", () => { expect(siteOf("149.154.175.50")).toBe("149.154.175.50"); }); }); + +describe("appKeyOf", () => { + test("names an app, a program, or a runtime by the folder it ran in", () => { + const flow = (extra: Partial) => + ({ + clientName: null, + clientBundleId: null, + clientPath: null, + clientCwd: null, + ...extra, + }) as Flow; + const flows = [ + flow({ clientName: "Slack", clientBundleId: "com.tinyspeck.slackmacgap" }), + flow({ + clientName: "node", + clientPath: "/opt/homebrew/bin/node", + clientCwd: "/src/fieldwork", + }), + flow({ + clientName: "nsurlsessiond", + clientPath: "/usr/libexec/nsurlsessiond", + clientCwd: "/", + }), + flow({}), + ]; + expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]); + }); +}); diff --git a/apps/yaak-proxy/lib/search.ts b/apps/yaak-proxy/lib/search.ts index 8cd2f4046..7183554c0 100644 --- a/apps/yaak-proxy/lib/search.ts +++ b/apps/yaak-proxy/lib/search.ts @@ -1,147 +1,6 @@ -import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; import { getDomain } from "tldts"; -/** - * A search over every flow in the database: the search box's terms, its field terms, and - * whether System's flows are hidden, as `list_flows` takes them. - */ -export type FlowQuery = Pick< - FlowFilter, - "q" | "under" | "app" | "cwd" | "system" | "tunnelled" | "any" ->; - -/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */ -export interface SearchToken { - field: string | null; - value: string; - negated: boolean; -} - -/** - * The search box's words. Values may be quoted, `@app:"Google Chrome"`; `OR` and `AND` between - * words are dropped here, and [`clausesOf`] splits on `OR`. - */ -export function tokenize(text: string): SearchToken[] { - return clausesOf(text).flat(); -} - -/** - * The search box's words in the clauses `OR` separates, each a group of terms that must all - * match: `@app:Slack @host:a.com OR @app:Chrome @host:b.com` is two. `AND` is dropped, since - * terms side by side must all match anyway. - */ -export function clausesOf(text: string): SearchToken[][] { - const clauses: SearchToken[][] = [[]]; - const re = /(-)?(?:@([a-z]+):)?(?:"([^"]*)"?|(\S+))/gi; - for (const m of text.matchAll(re)) { - const [, minus, field, quoted, bare] = m; - const value = quoted ?? bare ?? ""; - if (field == null && minus == null && quoted == null && value === "OR") { - clauses.push([]); - continue; - } - if (field == null && minus == null && quoted == null && value === "AND") continue; - if (field == null && value === "" && quoted == null) continue; - clauses.at(-1)?.push({ field: field?.toLowerCase() ?? null, value, negated: minus != null }); - } - return clauses.filter((c) => c.length > 0); -} - -/** `@field:value`, quoting a value with spaces or quotes in it */ -export function formatFieldFilter(field: string, value: string, negated = false): string { - const v = /[\s"]/.test(value) || value === "" ? `"${value.replaceAll('"', "")}"` : value; - return `${negated ? "-" : ""}@${field}:${v}`; -} - -/** - * The search box's text as a query, or null when it narrows nothing. Clauses `OR` separates are - * groups, each judged whole, so two sidebar rows under different apps match what each does and - * not every pairing of their apps and hosts. Within a clause, `@app` terms match any of their - * apps; `@host` terms any of their hosts, each with the `@path` terms after it; `@is:system` - * keeps only macOS's daemons and `-@is:system` drops them, as hiding System does when the - * clause says neither. `@is:tunnelled` keeps only HTTPS passed through undecrypted, and - * `-@is:tunnelled` drops it. Other words match the URL, the method, the status or the type. - */ -export function parseSearch(text: string, hideSystem = false): FlowQuery | null { - const clauses = clausesOf(text); - if (clauses.length <= 1) return parseClause(clauses[0] ?? [], hideSystem); - const any: FlowQuery[] = []; - for (const clause of clauses) { - const group = parseClause(clause, hideSystem); - // A clause that narrows nothing takes in every flow, so the whole does too. - if (group == null) return hideSystem ? { system: false } : null; - any.push(group); - } - return { any }; -} - -function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null { - const words: string[] = []; - const apps: string[] = []; - const cwds: string[] = []; - // Each host with the paths written after it. - const hosts: { host: string; paths: string[] }[] = []; - let system: boolean | undefined = hideSystem ? false : undefined; - let tunnelled: boolean | undefined; - for (const t of tokens) { - if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value); - else if (t.field === "cwd" && !t.negated && t.value !== "") cwds.push(t.value); - else if (t.field === "host" && !t.negated && t.value !== "") { - hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] }); - } else if (t.field === "path" && !t.negated && t.value !== "") { - const path = t.value.replace(/\/?\*$/, "").replace(/^\/+/, ""); - const host = hosts.at(-1); - // A path with no host to sit under matches anywhere in the URL. - if (host == null) { - if (path !== "") words.push(`/${path}`); - } else { - host.paths.push(path); - } - } else if (t.field === "is" && t.value.toLowerCase() === "system") system = !t.negated; - else if (t.field === "is" && t.value.toLowerCase() === "tunnelled") tunnelled = !t.negated; - else if (t.field == null && !t.negated) words.push(t.value); - // Fields and exclusions the query language will add are ignored until it lands. - } - const under: string[] = []; - for (const { host, paths } of hosts) { - if (paths.length === 0 || host.startsWith("*.")) under.push(host); - else for (const p of paths) under.push(p === "" ? host : `${host}/${p}`); - } - const q = words.join(" ").trim(); - const query: FlowQuery = { - ...(q === "" ? {} : { q }), - ...(under.length > 0 ? { under } : {}), - ...(apps.length > 0 ? { app: apps } : {}), - ...(cwds.length > 0 ? { cwd: cwds } : {}), - ...(system == null ? {} : { system }), - ...(tunnelled == null ? {} : { tunnelled }), - }; - return Object.keys(query).length === 0 ? null : query; -} - -/** - * Wider searches for one that matched nothing: the text with each of its terms left out in turn, - * without repeats. With groups `OR` separates, a term is left out of one group at a time and - * the groups kept, since each group alone is narrower still; a group left with nothing would - * take in every flow, so it is not offered. - */ -export function widerQueries(text: string): string[] { - const format = (t: SearchToken) => - t.field == null - ? `${t.negated ? "-" : ""}${/\s/.test(t.value) ? `"${t.value}"` : t.value}` - : formatFieldFilter(t.field, t.value, t.negated); - const clauses = clausesOf(text).map((c) => c.map(format)); - const out = new Set(); - clauses.forEach((clause, i) => { - if (clause.length <= 1) return; - clause.forEach((_, j) => { - const wider = clauses.map((c, k) => (k === i ? c.filter((_, l) => l !== j) : c)); - out.add(wider.map((c) => c.join(" ")).join(" OR ")); - }); - }); - return [...out]; -} - /** The folder a program outside an app ran in */ export function folderOf(flow: Pick): string | null { if (flow.clientBundleId != null) return null; @@ -202,8 +61,8 @@ const SYSTEM_PROGRAMS = new Set([ /** * A flow from one of macOS's own daemons: a process outside an app bundle whose executable is * under `/System`, `/usr/libexec` and the like, or one of a few daemons by name, but not WebKit's - * helpers, which send apps' traffic. `is_system` in `crates-proxy/yaak-proxy-lib`, which the - * database filters by, follows the same rules. + * helpers, which send apps' traffic. `is_system` in `crates-proxy/yaak-proxy-lib`, which + * `@is:system` searches by, follows the same rules. */ export function isSystemFlow(flow: Pick): boolean { const path = flow.clientPath; @@ -213,66 +72,6 @@ export function isSystemFlow(flow: Pick): return SYSTEM_DIRS.some((dir) => path.startsWith(dir)) || SYSTEM_PROGRAMS.has(name); } -/** - * Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a - * search as it arrives. Every term must match the URL, the method, the status or the content type. - */ -export function matchesQuery(flow: Flow, query: FlowQuery): boolean { - for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) { - if (term === "") continue; - const matched = - flow.url.toLowerCase().includes(term) || - flow.method.toLowerCase() === term || - (flow.contentType?.toLowerCase().includes(term) ?? false) || - matchesStatus(flow.status, term); - if (!matched) return false; - } - if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false; - if (query.app != null && !query.app.some((app) => matchesApp(flow, app))) return false; - if (query.cwd != null && !query.cwd.some((cwd) => matchesCwd(flow, cwd))) return false; - if (query.system != null && isSystemFlow(flow) !== query.system) return false; - if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false; - // One of the groups, each judged whole, as the database judges them. - if (query.any != null && query.any.length > 0) { - return query.any.some((group) => matchesQuery(flow, group)); - } - return true; -} - -/** By bundle id or path exactly, or by name or folder in any case */ -function matchesApp(flow: Flow, app: string): boolean { - if ((flow.clientBundleId ?? flow.clientPath) === app) return true; - const lower = app.toLowerCase(); - return flow.clientName?.toLowerCase() === lower || folderOf(flow)?.toLowerCase() === lower; -} - -/** By the whole working directory exactly, or its folder's name in any case */ -function matchesCwd(flow: Flow, cwd: string): boolean { - if (flow.clientCwd == null) return false; - return flow.clientCwd === cwd || folderName(flow.clientCwd)?.toLowerCase() === cwd.toLowerCase(); -} - -/** `404` exactly, `40` as the start of one, or a class such as `4xx` */ -function matchesStatus(status: number | null, term: string): boolean { - if (status == null) return false; - const klass = /^([1-9])xx$/.exec(term); - if (klass != null) return Math.trunc(status / 100) === Number(klass[1]); - if (!/^\d{1,3}$/.test(term)) return false; - return term.length === 3 ? status === Number(term) : String(status).startsWith(term); -} - -/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */ -function isUnder(url: string, node: string): boolean { - const path = sidebarPath(url); - if (node.startsWith("*.") && !node.includes("/")) { - const site = node.slice(2).toLowerCase(); - const host = hostnameOf(path.split("/")[0] ?? ""); - return host === site || host.endsWith(`.${site}`); - } - const want = normalizeNode(node); - return path === want || path.startsWith(`${want}/`); -} - /** A host without its port, keeping an IPv6 address's brackets */ export function hostnameOf(host: string): string { if (host.startsWith("[")) { @@ -304,7 +103,7 @@ export function siteOf(host: string): string { * Where the sidebar places a URL: its host, lowercased and without its scheme's default port, * then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL * without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which - * the database filters by, follows the same rules. + * `@host` searches by, follows the same rules. */ export function sidebarPath(url: string): string { const schemeEnd = url.indexOf("://"); @@ -326,11 +125,3 @@ export function sidebarPath(url: string): string { const segments = path.split("/").filter((s) => s !== ""); return [host, ...segments].join("/"); } - -/** A sidebar node as `sidebarPath` writes it, its host lowercased */ -function normalizeNode(node: string): string { - const slash = node.indexOf("/"); - return slash === -1 - ? node.toLowerCase() - : `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`; -} diff --git a/apps/yaak-proxy/lib/searchBox.ts b/apps/yaak-proxy/lib/searchBox.ts new file mode 100644 index 000000000..ffac3bd24 --- /dev/null +++ b/apps/yaak-proxy/lib/searchBox.ts @@ -0,0 +1,79 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { QueryDiagnostic, SavedQuery } from "@yaakapp-internal/proxy-lib"; +import type { QueryProblem } from "@yaakapp-internal/ui/editor"; +import { asOneTerm } from "./query"; +import { rpc } from "./rpc"; + +/** How long values read for completion are offered before they are read again */ +const VALUES_MS = 10_000; + +const seenValues = new Map(); +const reading = new Map>(); + +/** + * The values the proxy has seen for field paths, for completing them: read from the database + * the first time each is asked for, and again once they are a few seconds old, so the list + * keeps up with the traffic without a read per keystroke. + */ +export async function loadSeenValues(paths: string[]): Promise> { + const now = Date.now(); + const entries = await Promise.all( + paths.map(async (path): Promise<[string, string[]]> => { + const cached = seenValues.get(path); + if (cached != null && now - cached.at < VALUES_MS) return [path, cached.values]; + let read = reading.get(path); + if (read == null) { + read = rpc("query_values", { path }) + .then((values) => { + seenValues.set(path, { values, at: Date.now() }); + return values; + }) + .catch(() => cached?.values ?? []) + .finally(() => reading.delete(path)); + reading.set(path, read); + } + return [path, await read]; + }), + ); + return Object.fromEntries(entries); +} + +const PROBLEM_LABELS: Record = { + unknownField: "Unknown field", + invalidValue: "Invalid value", + notYet: "Not yet", +}; + +/** The terms of a search the backend cannot run, as the box underlines them */ +export function problemsOf(diagnostics: QueryDiagnostic[]): QueryProblem[] { + return diagnostics.map((d) => ({ + from: d.start, + to: d.end, + label: PROBLEM_LABELS[d.kind], + quiet: d.kind === "notYet", + })); +} + +export async function searchProblems(text: string): Promise { + if (text.trim() === "") return []; + return problemsOf(await rpc("validate_query", { query: text })); +} + +/** + * Saved searches whose names hold the word being typed, each replacing it with its search. One + * with `OR` at its top goes in parentheses, so it stays one group beside the box's other terms. + */ +export function savedCompletions(saved: SavedQuery[], typed: string): Completion[] { + const needle = typed.toLowerCase(); + return saved + .filter((s) => s.name.toLowerCase().includes(needle)) + .map((s) => { + const insert = asOneTerm(s.query); + return { + label: s.name, + detail: s.query, + type: "text", + apply: insert, + }; + }); +} diff --git a/apps/yaak-proxy/lib/searchChanges.test.ts b/apps/yaak-proxy/lib/searchChanges.test.ts new file mode 100644 index 000000000..6801cff8a --- /dev/null +++ b/apps/yaak-proxy/lib/searchChanges.test.ts @@ -0,0 +1,96 @@ +import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { emptyWindow, type FlowWindow } from "./flowWindow"; +import { changesFor, FlowEvents } from "./searchChanges"; + +const none = () => {}; + +function write(id: string, matches: boolean, key = "k"): ModelPayload { + return { + model: { id, startedAt: "2026-11-07 12:00:00", updatedAt: "2026-11-07 12:00:00" } as Flow, + change: { type: "upsert", created: false }, + search: { key, matches }, + }; +} + +describe("changesFor", () => { + test("takes matching flows and drops those that stop matching", () => { + const win: FlowWindow = { ...emptyWindow, flows: [write("a", true).model] }; + const kept = changesFor( + "k", + win, + [write("a", false), write("b", true), write("c", false)], + none, + ); + expect(kept.map((c) => [c.model.id, c.change.type])).toEqual([ + ["a", "delete"], + ["b", "upsert"], + ]); + }); + + test("a flow that joins and stops matching in one batch leaves again", () => { + const kept = changesFor("k", emptyWindow, [write("a", true), write("a", false)], none); + expect(kept.map((c) => [c.model.id, c.change.type])).toEqual([ + ["a", "upsert"], + ["a", "delete"], + ]); + }); + + test("leaves flows matched against another search to be matched again", () => { + const later: string[] = []; + const kept = changesFor("k", emptyWindow, [write("a", true, "old")], (unknown) => + later.push(...unknown.map((c) => c.model.id)), + ); + expect([kept, later]).toEqual([[], ["a"]]); + }); +}); + +describe("FlowEvents", () => { + test("drops a late answer for a flow a newer event removed, though the window no longer holds it", () => { + const events = new FlowEvents(); + const old = write("a", true, "old"); + events.arrived([old]); + // Under the new search the flow stops matching and leaves the window... + events.arrived([write("a", false)]); + // ...and the re-check of the old event answers after + expect(events.current([old])).toEqual([]); + }); + + test("drops a late answer for a flow the sweep removed", () => { + const events = new FlowEvents(); + const old = write("a", true, "old"); + events.arrived([old]); + events.removed(["a"]); + expect(events.current([old])).toEqual([]); + }); + + test("keeps an answer no newer event has passed, and one for other flows", () => { + const events = new FlowEvents(); + const a = write("a", true, "old"); + const b = write("b", true, "old"); + events.arrived([a, b]); + events.arrived([write("c", true)]); + expect(events.current([a, b])).toEqual([a, b]); + // Not noted: nothing to judge it by + const unseen = write("d", true, "old"); + expect(events.current([unseen])).toEqual([unseen]); + }); + + test("past what it remembers, drops what it can no longer judge", () => { + const events = new FlowEvents(2); + const a = write("a", true, "old"); + events.arrived([a, write("b", true), write("c", true)]); + expect(events.current([a])).toEqual([]); + const c = write("c", true, "old"); + events.arrived([c]); + expect(events.current([c])).toEqual([c]); + }); + + test("drops answers from before a clear", () => { + const events = new FlowEvents(); + const a = write("a", true, "old"); + events.arrived([a]); + events.reset(); + expect(events.current([a])).toEqual([]); + }); +}); diff --git a/apps/yaak-proxy/lib/searchChanges.ts b/apps/yaak-proxy/lib/searchChanges.ts new file mode 100644 index 000000000..314590306 --- /dev/null +++ b/apps/yaak-proxy/lib/searchChanges.ts @@ -0,0 +1,105 @@ +import type { ModelPayload } from "@yaakapp-internal/proxy-lib"; +import type { FlowWindow } from "./flowWindow"; + +/** + * The changes a search's window `win` takes: flows the backend matched against the search, by + * its `key`, as it sent their events, and every deletion. A flow the window holds that no longer + * matches, such as a pending flow that completed under `@is:pending`, leaves it, even when it + * joined earlier in the same batch. Flows matched against another search, sent as the search + * changed or read from the database while another process owns the engine, go to `unknown` to + * be matched again. + */ +export function changesFor( + key: string, + win: FlowWindow, + changes: ModelPayload[], + unknown: (changes: ModelPayload[]) => void, +): ModelPayload[] { + const others: ModelPayload[] = []; + // What the window holds as each change applies + const held = new Set(win.flows.map((f) => f.id)); + const kept: ModelPayload[] = []; + for (const c of changes) { + if (c.change.type === "delete") { + held.delete(c.model.id); + kept.push(c); + } else if (c.search?.key !== key) { + others.push(c); + } else if (c.search.matches) { + held.add(c.model.id); + kept.push(c); + } else if (held.delete(c.model.id)) { + kept.push({ ...c, change: { type: "delete" } }); + } + } + if (others.length > 0) unknown(others); + return kept; +} + +/** Flows whose newest event `FlowEvents` remembers, at most */ +const REMEMBERED = 20_000; + +/** + * The order flow events arrived in, so an answer that comes later than newer events for the + * same flow can be told apart: a re-check started before the search changed, say, whose flow a + * newer event has since updated or removed, even from the window. + */ +export class FlowEvents { + private count = 0; + /** Each event's place, by the payload as it arrived */ + private seqs = new WeakMap(); + /** The newest event's place, by flow, removals included, the least recent first */ + private latest = new Map(); + /** Places at or below this may have newer events no longer remembered */ + private floor = 0; + + constructor(private readonly remembered = REMEMBERED) {} + + /** Notes events as they arrive */ + arrived(changes: ModelPayload[]) { + for (const c of changes) { + this.count++; + this.seqs.set(c, this.count); + this.note(c.model.id); + } + } + + /** Notes flows removed with no event of their own, as the keep-traffic sweep removes them */ + removed(ids: string[]) { + for (const id of ids) { + this.count++; + this.note(id); + } + } + + private note(id: string) { + // Moved to the end, so the least recent go first + this.latest.delete(id); + this.latest.set(id, this.count); + for (const [oldest, seq] of this.latest) { + if (this.latest.size <= this.remembered) break; + this.latest.delete(oldest); + this.floor = Math.max(this.floor, seq); + } + } + + /** + * Of the changes a late answer is for, those no newer event for their flow has passed. One + * from before the oldest event remembered is dropped, as it can't be judged; the next count + * and reload correct the list. One whose arrival was not noted is kept. + */ + current(changes: T[]): T[] { + return changes.filter((c) => { + const seq = this.seqs.get(c); + if (seq == null) return true; + const newest = this.latest.get(c.model.id); + return newest == null ? seq > this.floor : seq >= newest; + }); + } + + /** Forgets everything, as a clear does */ + reset() { + this.latest.clear(); + this.floor = this.count; + } +} diff --git a/apps/yaak-proxy/lib/sidebarTree.test.ts b/apps/yaak-proxy/lib/sidebarTree.test.ts index 002a7bb15..f1f44dac3 100644 --- a/apps/yaak-proxy/lib/sidebarTree.test.ts +++ b/apps/yaak-proxy/lib/sidebarTree.test.ts @@ -1,7 +1,7 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { TreeNode } from "@yaakapp-internal/ui"; import { describe, expect, test } from "vite-plus/test"; -import { parseSearch } from "./search"; +import { parseQuery } from "@yaakapp-internal/query"; import { buildSidebarTree, itemsById, @@ -83,8 +83,9 @@ describe("buildSidebarTree", () => { const query = (id: string) => items.get(id)?.query; expect(query("all")).toBe(""); expect(query("app:fieldwork")).toBe("@app:fieldwork"); + // A path row takes in what is under the path. expect(query("app:fieldwork|path:api.github.com/repos")).toBe( - "@app:fieldwork @host:api.github.com @path:/repos", + "@app:fieldwork @host:api.github.com @path:/repos/*", ); expect(query("site:github.com")).toBe("@host:*.github.com"); expect(query("system")).toBe("@is:system"); @@ -93,12 +94,12 @@ describe("buildSidebarTree", () => { expect(query("app:trustd|host:ocsp.apple.com")).toBe( "@is:system @app:trustd @host:ocsp.apple.com", ); - expect(parseSearch(query("app:trustd") ?? "", true)?.system).toBe(true); - // What each writes, read back, covers the flows the row counts. + // What each writes reads back as the terms it wrote. for (const item of items.values()) { - const parsed = parseSearch(item.query); - if (parsed == null || item.kind === "group") continue; + if (item.kind === "group" || item.kind === "all") continue; + const parsed = parseQuery(item.query); expect(parsed, item.id).toBeTruthy(); + expect(parsed?.type, item.id).not.toBe("Or"); } }); }); @@ -164,7 +165,7 @@ describe("programs outside an app", () => { expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.query).toBe( "@app:claude @cwd:yaak @host:statsig.anthropic.com", ); - expect(parseSearch(query)).toEqual({ app: ["claude"], cwd: ["yaak"] }); + expect(parseQuery(query)?.type).toBe("And"); }); }); @@ -215,12 +216,10 @@ describe("selection and the search text", () => { pick("app:Slack|site:slack.com"), ]); expect(text).toBe("@app:fieldwork @host:api.github.com OR @app:Slack @host:*.slack.com"); - expect(parseSearch(text)).toEqual({ - any: [ - { app: ["fieldwork"], under: ["api.github.com"] }, - { app: ["Slack"], under: ["*.slack.com"] }, - ], - }); + // Two groups, each judged whole + const parsed = parseQuery(text); + expect(parsed?.type).toBe("Or"); + expect(parsed?.type === "Or" ? parsed.children.map((c) => c.type) : []).toEqual(["And", "And"]); }); test("writes the selected rows' queries, OR'd, or nothing for All traffic", () => { @@ -240,3 +239,29 @@ describe("selection and the search text", () => { expect(queryInText("", "card")).toBe(false); }); }); + +describe("saved searches", () => { + test("follow All traffic, each a row with its count that writes its query", () => { + const saved = [ + { id: "sq_1", name: "Errors", query: "@status:>=400", count: 12 }, + { id: "sq_2", name: "Stripe", query: "@host:*.stripe.com", count: 0 }, + ]; + const root = buildSidebarTree(flows, 4, saved); + expect((root.children ?? []).slice(0, 2).flatMap((c) => shape(c))).toEqual([ + "All traffic 4", + "Saved 0", + " Errors 12", + " Stripe 0", + ]); + const items = itemsById(root); + expect(items.get("saved:sq_1")).toMatchObject({ + kind: "saved", + query: "@status:>=400", + savedId: "sq_1", + }); + // Rows, not folders + const group = (root.children ?? []).find((c) => c.item.id === "saved"); + expect(group?.children?.every((c) => c.children == null)).toBe(true); + expect(itemsById(buildSidebarTree(flows, 4)).has("saved")).toBe(false); + }); +}); diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index 3e1066689..76eaa535c 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -1,20 +1,13 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { TreeNode } from "@yaakapp-internal/ui"; import type { DecryptSubject } from "./decrypt"; -import { - appKeyOf, - folderOf, - formatFieldFilter, - isSystemFlow, - runtimeFolderOf, - sidebarPath, - siteOf, - tokenize, -} from "./search"; +import { fieldTerm, pathTerm, siteTerm, termKeys } from "./query"; +import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search"; export type SidebarKind = | "all" | "tunnelled" + | "saved" | "group" | "app" | "folder" @@ -23,8 +16,8 @@ export type SidebarKind = | "path"; /** - * A row of the sidebar: All traffic, a group, an app, a program's working directory, or a site, - * host or path under one + * A row of the sidebar: All traffic, a saved search, a group, an app, a program's working + * directory, or a site, host or path under one */ export interface SidebarItem { id: string; @@ -42,10 +35,21 @@ export interface SidebarItem { query: string; /** What Decrypt in the row's menu decrypts: its host, site or app */ decrypt?: DecryptSubject; + /** The saved search a Saved row is, for renaming and deleting it */ + savedId?: string; +} + +/** A saved search as the sidebar lists it, with how many flows it covers */ +export interface SavedRow { + id: string; + name: string; + query: string; + count: number; } /** The ids of All traffic and the groups */ export const ALL_ID = "all"; +export const SAVED_ID = "saved"; export const APPS_ID = "apps"; export const DOMAINS_ID = "domains"; export const SYSTEM_ID = "system"; @@ -83,13 +87,13 @@ function placeOf(flow: Flow): Place { const [host = "", ...segments] = schemeless ? [path] : path.split("/"); const site = siteOf(host); const app = appKeyOf(flow); - const hostQuery = formatFieldFilter("host", host); + const hostQuery = fieldTerm("host", host); const rows: PlaceRow[] = [ { id: `site:${site}`, kind: "site", label: site, - query: formatFieldFilter("host", `*.${site}`), + query: siteTerm(site), decrypt: { kind: "site", site }, }, { @@ -107,7 +111,7 @@ function placeOf(flow: Flow): Place { id: `path:${host}${p}`, kind: "path", label: `/${segment}`, - query: `${hostQuery} ${formatFieldFilter("path", p)}`, + query: `${hostQuery} ${pathTerm(p)}`, }); } const system = isSystemFlow(flow); @@ -123,7 +127,7 @@ function placeOf(flow: Flow): Place { })); } else { const folderId = `app:${app}|cwd:${folder}`; - const folderQuery = `${appQuery} ${formatFieldFilter("cwd", folder)}`; + const folderQuery = `${appQuery} ${fieldTerm("cwd", folder)}`; appRows = [ { id: folderId, kind: "folder", label: folder, query: folderQuery }, ...rows.map((r) => ({ @@ -144,8 +148,8 @@ function placeOf(flow: Flow): Place { * while System is hidden from All traffic. */ function appQueryFor(app: string, system: boolean): string { - const query = formatFieldFilter("app", app); - return system ? `${formatFieldFilter("is", "system")} ${query}` : query; + const query = fieldTerm("app", app); + return system ? `${fieldTerm("is", "system")} ${query}` : query; } /** A node being built, its children by id */ @@ -209,10 +213,14 @@ function group(id: string, label: string): Trie { * The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then * Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with * nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through - * undecrypted, when there is any. `total` is All traffic's count: every flow it covers in the - * database. + * undecrypted, when there is any, and Saved lists the saved searches, each with its count from + * the database. `total` is All traffic's count: every flow it covers in the database. */ -export function buildSidebarTree(flows: Flow[], total: number): TreeNode { +export function buildSidebarTree( + flows: Flow[], + total: number, + saved: SavedRow[] = [], +): TreeNode { const root: TreeNode = { item: { id: "root", kind: "group", label: "", count: 0, query: "" }, parent: null, @@ -223,7 +231,7 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode 0) { + const node: TreeNode = { + item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" }, + parent: root, + depth: 1, + children: [], + draggable: false, + }; + node.children = saved.map((s) => ({ + item: { + id: `saved:${s.id}`, + kind: "saved", + label: s.name, + count: s.count, + query: s.query, + savedId: s.id, + }, + parent: node, + depth: 2, + draggable: false, + })); + root.children?.push(node); + } for (const g of [apps, domains, system]) { if (g.children.size === 0) continue; const node: TreeNode = { @@ -318,12 +349,8 @@ export function selectionText(items: SidebarItem[]): string { * stays selected while it is, so typing after selecting keeps it. */ export function queryInText(query: string, text: string): boolean { - const have = new Set(tokenize(text).map(tokenKey)); - const want = tokenize(query); + const have = new Set(termKeys(text)); + const want = termKeys(query); if (want.length === 0) return have.size === 0; - return want.every((t) => have.has(tokenKey(t))); -} - -function tokenKey(t: { field: string | null; value: string; negated: boolean }): string { - return `${t.negated ? "-" : ""}${t.field ?? ""}:${t.value.toLowerCase()}`; + return want.every((t) => have.has(t)); } diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index 59f86eb9b..fc9655c4d 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -1,4 +1,9 @@ -import type { Flow, GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib"; +import type { + Flow, + GetProxyStateResponse, + ModelPayload, + SavedQuery, +} from "@yaakapp-internal/proxy-lib"; import { selectedIdsFamily } from "@yaakapp-internal/ui"; import { atom, type createStore } from "jotai"; import { atomWithStorage } from "jotai/utils"; @@ -20,8 +25,9 @@ import { WINDOW_SIZE, windowFromPage, } from "./flowWindow"; +import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query"; import { listen, rpc } from "./rpc"; -import { type FlowQuery, matchesQuery, parseSearch } from "./search"; +import { changesFor, FlowEvents } from "./searchChanges"; import { ALL_ID, APPS_ID, @@ -29,6 +35,8 @@ import { DOMAINS_ID, itemsById, queryInText, + SAVED_ID, + type SavedRow, selectionText, } from "./sidebarTree"; @@ -40,6 +48,8 @@ const FLUSH_MS = 100; const RECOUNT_MS = 1000; /** How often the database is read for new flows while another process owns the engine */ const POLL_MS = 1000; +/** How often a search counting back from now is read again, as flows age in and out of it */ +const AGE_REFRESH_MS = 15_000; /** How often the engine's owner is checked while it is another process */ const OWNER_POLL_MS = 2000; @@ -90,9 +100,26 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde /** The search the list shows, from the search box's settled text, or null for all traffic */ export const activeQueryAtom = atom((get) => - parseSearch(get(searchTermsAtom), get(hideSystemAtom)), + activeQuery(get(searchTermsAtom), get(hideSystemAtom)), ); +/** The saved searches, in their order */ +export const savedQueriesAtom = atom([]); + +/** How many flows each saved search covers, by id, counted in the database */ +export const savedCountsAtom = atom>({}); + +/** The saved searches as the sidebar lists them */ +const savedRowsAtom = atom((get) => { + const counts = get(savedCountsAtom); + return get(savedQueriesAtom).map((s) => ({ + id: s.id, + name: s.name, + query: s.query, + count: counts[s.id] ?? 0, + })); +}); + /** Writes `text` into the search box at once, as the sidebar does, without waiting for typing */ export const setSearchTextAtom = atom(null, (_get, set, text: string) => { set(searchTextAtom, text); @@ -119,7 +146,7 @@ const SIDEBAR_MS = 1000; /** The sidebar's rows, from the flows loaded */ export const sidebarTreeAtom = atom((get) => - buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom)), + buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom), get(savedRowsAtom)), ); const storedColumnPrefsAtom = atomWithStorage( @@ -244,7 +271,7 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => { const merge = (win: FlowWindow) => { if (win.flows[0]?.id !== first.id) return win; const merged = prependOlder(win, withoutRemoved(res.flows), PAGE_SIZE); - const kept = search == null ? changes : changesFor(search.query, changes); + const kept = search == null ? changes : changesOf(search, merged, changes); // Counted when they first applied. return { ...applyChanges(merged, kept), total: merged.total }; }; @@ -261,9 +288,47 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => { } }); -/** The changes a search's window takes: flows it matches, and every deletion */ -function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] { - return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query)); +/** `changesFor`, with flows matched against another search sent to be matched again */ +function changesOf( + search: Pick, + win: FlowWindow, + changes: ModelPayload[], +): ModelPayload[] { + return changesFor(search.key, win, changes, (unknown) => matchLater(search, unknown)); +} + +/** The store `syncFlows` keeps, for answers that come after the changes they are for */ +let syncedStore: Store | null = null; + +/** The order flow events arrived in, for judging answers that come late */ +const flowEvents = new FlowEvents(); + +/** + * Matches flows against the search in the database, then has its window take them as it takes + * events matched as they were sent. A flow a newer event updated or removed meanwhile, in the + * window or not, keeps what that event left. + */ +function matchLater(search: Pick, changes: ModelPayload[]) { + const store = syncedStore; + if (store == null) return; + const ids = [...new Set(changes.map((c) => c.model.id))]; + rpc("match_flows", { filter: search.query, ids }) + .then((matched) => { + const covered = new Set(matched); + const marked = flowEvents.current(changes).map((c) => ({ + ...c, + search: { key: search.key, matches: covered.has(c.model.id) }, + })); + if (marked.length === 0) return; + const following = store.get(followingAtom); + store.set(searchWindowAtom, (s) => { + if (s?.key !== search.key) return s; + if (s.loading) return { ...s, buffered: [...s.buffered, ...marked] }; + const next = applyChanges(s.win, changesOf(s, s.win, marked)); + return { ...s, win: following ? trimWindow(next) : next }; + }); + }) + .catch((err) => console.warn("Failed to match flows", err)); } /** @@ -274,6 +339,7 @@ function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] { * way. */ export function syncFlows(store: Store) { + syncedStore = store; let pending: ModelPayload[] = []; let timer: ReturnType | null = null; let loaded = false; @@ -297,7 +363,7 @@ export function syncFlows(store: Store) { if (s == null) return s; // A matching flow written after the page's snapshot would otherwise be missed. if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] }; - const next = applyChanges(s.win, changesFor(s.query, changes)); + const next = applyChanges(s.win, changesOf(s, s.win, changes)); return { ...s, win: following ? trimWindow(next) : next }; }); const selected = store.get(selectedFlowAtom); @@ -309,6 +375,7 @@ export function syncFlows(store: Store) { } function receive(changes: ModelPayload[]) { + flowEvents.arrived(changes); pending.push(...changes); if (timer == null) timer = setTimeout(flush, FLUSH_MS); } @@ -326,10 +393,11 @@ export function syncFlows(store: Store) { ); }); if (store.get(hideSystemAtom)) { - void rpc("list_flows", { system: false, limit: 0 }).then((res) => { + void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => { if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total); }); } + countSaved(); const search = store.get(searchWindowAtom); if (search == null || search.loading) return; void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => { @@ -340,21 +408,60 @@ export function syncFlows(store: Store) { }); } + // Saved searches, and their counts, which change with the traffic. + function loadSaved() { + rpc("list_saved_queries", {}) + .then((saved) => { + store.set(savedQueriesAtom, saved); + countSaved(); + }) + .catch((err) => console.warn("Failed to list saved searches", err)); + } + let counting = false; + let countAgain = false; + function countSaved() { + if (store.get(savedQueriesAtom).length === 0) return; + // One count at a time; one asked for meanwhile runs after it, with the latest traffic. + if (counting) { + countAgain = true; + return; + } + counting = true; + rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) }) + .then((counts) => + store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))), + ) + .catch((err) => console.warn("Failed to count saved searches", err)) + .finally(() => { + counting = false; + if (countAgain) { + countAgain = false; + countSaved(); + } + }); + } + listen("saved_queries_changed", loadSaved); + loadSaved(); + listen("model_write", (payload) => { seenId = payload.model.id; receive([payload]); }); // Loads again rather than only emptying, for a flow written between the clear and the event. + // Saved searches too, which could not be read while the database was locked. listen("flows_cleared", () => { + flowEvents.reset(); store.set(emptyFlowsAtom); pending = []; load(); + loadSaved(); }); // The oldest flows, swept to keep the capture under its size limit: dropped wherever they // are held, and the totals counted again from the database. listen("flows_removed", ({ ids }) => { + flowEvents.removed(ids); const removed = new Set(ids); const drop = (win: FlowWindow) => win.flows.some((f) => removed.has(f.id)) @@ -399,21 +506,31 @@ export function syncFlows(store: Store) { } reloadFlows = load; - /** Loads the first page of `query`'s flows into a window of its own, or drops it for null */ - function loadSearch(query: FlowQuery | null) { + /** + * Loads the first page of `query`'s flows into a window of its own, or drops it for null. The + * backend matches the flows written from now on against it, and their events say so. + */ + function loadSearch(query: FlowQuery | null, keepRows = false) { if (query == null) { store.set(searchWindowAtom, null); + void rpc("watch_query", { key: "" }).catch(() => {}); return; } // Unique to this load, so a slower answer to an earlier one with the same query, such as // one read before a clear, is dropped. const key = `${++searchLoads}:${JSON.stringify(query)}`; - store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] }); + // Read again, it keeps showing its rows until the new ones are in. + const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow; + store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] }); + void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch( + (err) => console.warn("Failed to watch the search", err), + ); const settle = (page: FlowWindow | null) => store.set(searchWindowAtom, (s) => { if (s?.key !== key) return s; // Applied again over the page: a flow already in it is replaced, not added. - const win = applyChanges(page ?? s.win, changesFor(query, s.buffered)); + const base = page ?? s.win; + const win = applyChanges(base, changesOf(s, base, s.buffered)); return { ...s, win: page == null ? win : { ...win, total: page.total }, @@ -451,11 +568,21 @@ export function syncFlows(store: Store) { } }); - // All traffic's count leaves System out while it is hidden. + // All traffic's count, and the saved searches', leave System out while it is hidden. store.sub(hideSystemAtom, () => { if (loaded) recount(); }); + // A search counting back from now, `@time:<5m`, is read again while it is shown and the list + // follows new traffic, so flows leave it and join it as they age. Saved ones are counted + // again, as their counts change with no traffic at all. + setInterval(() => { + if (anyCountsBackFromNow(store.get(savedQueriesAtom).map((q) => q.query))) countSaved(); + const s = store.get(searchWindowAtom); + if (s == null || s.loading || !store.get(followingAtom)) return; + if (countsBackFromNow(s.query.query)) loadSearch(s.query, true); + }, AGE_REFRESH_MS); + let activeKey = JSON.stringify(store.get(activeQueryAtom)); store.sub(activeQueryAtom, () => { const query = store.get(activeQueryAtom); @@ -576,7 +703,7 @@ export function syncSidebarSelection(store: Store) { lastRows = key; if (syncing || Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) return; // Group headings are not filters. - const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID); + const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); if (rows.length === 0) return; const items = itemsById(store.get(sidebarTreeAtom)); const selected = rows.flatMap((id) => { diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 0da1b2bc0..7e160c0b9 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -35,6 +35,7 @@ "babel-plugin-react-compiler": "^1.0.0", "typescript": "^5.8.3", "vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0", + "vite-plugin-wasm": "^3.5.0", "vite-plus": "^1.4.0" } } diff --git a/apps/yaak-proxy/vite.config.ts b/apps/yaak-proxy/vite.config.ts index 3d56934f6..73206609c 100644 --- a/apps/yaak-proxy/vite.config.ts +++ b/apps/yaak-proxy/vite.config.ts @@ -1,9 +1,13 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import wasm from "vite-plugin-wasm"; export default defineConfig({ - plugins: [react()], + // The search box's query language is a WebAssembly package + plugins: [wasm(), react()], build: { + // Top-level await, which the WebAssembly package loads with + target: "esnext", outDir: "../../dist/apps/yaak-proxy", emptyOutDir: true, }, diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml index bf52d7d8c..b0112ecb5 100644 --- a/crates-proxy/yaak-proxy-lib/Cargo.toml +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -36,6 +36,8 @@ yaak-lifecycle = { workspace = true } yaak-models = { workspace = true, features = ["sqlcipher"] } yaak-proxy = { workspace = true } yaak-proxy-helper = { workspace = true, features = ["ts"] } +# The search query language: parsing, the evaluator for live flows, completion. +yaak-query = { workspace = true } yaak-rpc = { workspace = true } yaak-templates = { workspace = true } diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts index 7e5b70058..98ca43b83 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts @@ -131,7 +131,12 @@ grpcMessage: string | null, * client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes * the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set. */ -tunnelled: boolean, }; +tunnelled: boolean, +/** + * An agent read the flow through the control API: its detail, a body or its messages. + * Never written by an upsert, which keeps what is stored. + */ +agentRead: boolean, }; export type FlowBody = "request" | "response"; @@ -239,10 +244,34 @@ grpcStatus?: number, */ grpcMessage?: string, }; -export type ModelPayload = { model: Flow, change: ModelChangeEvent, }; +export type ModelPayload = { model: Flow, change: ModelChangeEvent, +/** + * Whether the flow belongs in the list of the search the app shows, decided as the event + * is sent. None while no search is shown. + */ +search?: SearchMatch, }; export type ProxyHeader = { name: string, value: string, }; +export type SavedQuery = { id: string, name: string, +/** + * The search, as the search box takes it. + */ +query: string, +/** + * Where it sits in the list, lowest first. + */ +sortOrder: number, }; + +/** + * A written flow, matched against the search the app shows. + */ +export type SearchMatch = { +/** + * The search's key, as the app gave it with the search. + */ +key: string, matches: boolean, }; + /** * The streaming protocol a flow carries. */ diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index 2e70e0589..d94a860b5 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload } from "./gen_models"; +import type { Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models"; export type ActionInvocation = { "scope": "global", action: GlobalAction, }; @@ -58,6 +58,10 @@ path: string, */ cwd: string | null, }; +export type CountSavedQueriesRequest = { hideSystem?: boolean, }; + +export type CreateSavedQueryRequest = { name: string, query: string, }; + export type DecodeGrpcMessageRequest = { flowId: string, seq: number, /** * Decode without a schema even when one is found. @@ -111,6 +115,8 @@ entry: string, */ builtIn: boolean, }; +export type DeleteSavedQueryRequest = { id: string, }; + export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded"; export type DroppedHeader = { name: string, value: string, reason: DropReason, }; @@ -146,60 +152,19 @@ export type FilterBodyResponse = { content: string, error: string | null, }; export type FilterLanguage = "jsonpath" | "xpath"; /** - * Which flows a listing or a HAR export covers. Every field narrows it; none is all of them. + * Which flows a listing or a HAR export covers: a search, and a page of it. */ export type FlowFilter = { /** - * Search terms, any case, each of which must match the URL (host, path or query), the - * method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + * A search in the query language (`crate::query`): `stripe @status:4xx`, + * `@host:*.github.com -@is:system`. None or blank covers every flow. */ -q?: string, +query?: string, /** - * Part of the host, any case: `stripe` matches `api.stripe.com`. + * Leaves out macOS's own daemons, as hiding System does, unless the query says + * `@is:system` itself. */ -host?: string, -/** - * Only flows under one of these hosts or paths, as the app's sidebar names them: - * `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. A host - * written `*.github.com` is a site: `github.com` and every host below it. - */ -under?: Array, -/** - * Only flows from one of these apps: by bundle id, by path for a process outside an app - * bundle, by name in any case (`slack`), or, for a process outside an app, by the folder - * it ran in (`fieldwork`). - */ -app?: Array, -/** - * Only flows from a process that ran in one of these working directories: the whole path, - * or its folder's name in any case (`fieldwork`). With `app`, a program in a folder. - */ -cwd?: Array, -/** - * Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`] - * tells them by their executable. - */ -system?: boolean, -/** - * Only HTTPS connections tunnelled undecrypted (true), or none of them (false). - */ -tunnelled?: boolean, -/** - * Only flows one of these groups covers, each judged on its own: several sidebar rows - * selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every - * pairing of their apps and hosts. A group's `after`, `before` and `limit` do not apply. Not - * carried by a query string. - */ -any?: Array, -/** - * The method, any case. - */ -method?: string, -/** - * A status (`404`), a class of them (`4xx`), or `error` for flows that failed, gRPC calls - * that ended with a status other than OK among them. - */ -status?: string, +hideSystem?: boolean, /** * At most this many flows, the newest. */ @@ -382,6 +347,8 @@ messages: Array, */ total: number, }; +export type ListSavedQueriesRequest = Record; + export type ListSeenRequest = Record; export type ListWorkspacesRequest = Record; @@ -392,6 +359,11 @@ export type ListWorkspacesResponse = { */ yaakFound: boolean, workspaces: Array, }; +/** + * Which of some flows a search covers, for flows whose events were matched against another. + */ +export type MatchFlowsRequest = { filter: FlowFilter, ids: Array, }; + /** * A message of a streaming flow was recorded. */ @@ -436,6 +408,30 @@ export type ProxyState = "running" | "stopped"; export type ProxyStatePayload = { state: ProxyState, }; +/** + * A term the query cannot run, which then matches nothing. `start` and `end` count UTF-16 + * code units, as JavaScript does. + */ +export type QueryDiagnostic = { kind: QueryDiagnosticKind, path: string, start: number, end: number, }; + +/** + * What is wrong with a term, for the search box to mark. + */ +export type QueryDiagnosticKind = "unknownField" | "invalidValue" | "notYet"; + +export type QuerySchemaRequest = Record; + +/** + * The search's fields, for the search box's highlighting and completion. + */ +export type QuerySchemaResponse = { schema: import("@yaakapp-internal/query").Schema, }; + +export type QueryValuesRequest = { +/** + * A field's path: `host`, `header` for header names, `header.accept` for a header's values. + */ +path: string, }; + export type ReflectGrpcHostRequest = { /** * `host` or `host:port`, over TLS on 443 unless a port is given, or either after @@ -445,11 +441,13 @@ host: string, }; export type RemoveGrpcSchemaRequest = { id: string, }; +export type RenameSavedQueryRequest = { id: string, name: string, }; + export type ResetCapturedDataRequest = Record; -export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; +export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, /** @@ -513,6 +511,13 @@ authorization: boolean, }; export type SavedFlow = { requestId: string, name: string, }; +export type SavedQueriesChangedPayload = Record; + +/** + * How many flows a saved query covers. + */ +export type SavedQueryCount = { id: string, count: number, }; + export type SeenApp = { name: string, bundleId: string | null, }; /** @@ -591,4 +596,12 @@ export type SystemProxyStatusRequest = Record; export type TakeOverRequest = Record; +export type ValidateQueryRequest = { query: string, }; + +/** + * The search the app shows, which flows written from now on are matched against, their events + * naming `key`. No query stops it. + */ +export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, }; + export type WorkspaceSummary = { id: string, name: string, folders: Array, }; diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000012_search.sql b/crates-proxy/yaak-proxy-lib/migrations/00000012_search.sql new file mode 100644 index 000000000..939b111ae --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000012_search.sql @@ -0,0 +1,13 @@ +-- An agent read the flow through the control API: its detail, a body or its messages. +ALTER TABLE flows ADD COLUMN agent_read BOOLEAN NOT NULL DEFAULT FALSE; + +-- Searches saved by name, listed in the sidebar by `sort_order`. +CREATE TABLE saved_queries +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + name TEXT NOT NULL, + query TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); diff --git a/crates-proxy/yaak-proxy-lib/src/control.rs b/crates-proxy/yaak-proxy-lib/src/control.rs index b6bdb2e23..61b262eb2 100644 --- a/crates-proxy/yaak-proxy-lib/src/control.rs +++ b/crates-proxy/yaak-proxy-lib/src/control.rs @@ -554,7 +554,9 @@ async fn route(state: &ServerState, req: Request) -> Result { found(ctx, id)?; let limit = body_limit(&query)?; - json(&flows::detail(&ctx.db()?, &ctx.blobs()?, id, limit).await?) + let detail = flows::detail(&ctx.db()?, &ctx.blobs()?, id, limit).await?; + ctx.note_agent_read(id); + json(&detail) } (&Method::GET, ["flows", id, "request-body"]) => raw_body(ctx, id, FlowBody::Request).await, (&Method::GET, ["flows", id, "response-body"]) => { @@ -565,6 +567,7 @@ async fn route(state: &ServerState, req: Request) -> Result { @@ -584,6 +587,7 @@ async fn route(state: &ServerState, req: Request) -> Result json(&grpc::list(&ctx.db()?)?), @@ -687,6 +691,7 @@ async fn raw_body(ctx: &ProxyCtx, id: &str, which: FlowBody) -> Result bool { reason.starts_with(KEY_UNAVAILABLE) } -/// The functions filters use: `sidebar_path(url)`, the app sidebar's place for a URL, and -/// `under_site(url, site)`, for filtering by its nodes; `folder_name(dir)`, for naming a program -/// by its folder; and `is_system(bundle_id, path)`, for macOS's own daemons. +/// The functions searches use: `folder_name(dir)`, for naming a program by its folder; +/// `is_system(bundle_id, path)`, for macOS's own daemons; and those the query compiler reads +/// flows' values and tests them with ([`crate::query::add_functions`]). fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> { let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC; - conn.create_scalar_function("sidebar_path", 1, flags, |ctx| { - Ok(crate::flows::sidebar_path(&ctx.get::(0)?)) - })?; - conn.create_scalar_function("under_site", 2, flags, |ctx| { - Ok(crate::flows::under_site(&ctx.get::(0)?, &ctx.get::(1)?)) - })?; conn.create_scalar_function("folder_name", 1, flags, |ctx| { let dir = ctx.get::>(0)?; Ok(crate::flows::folder_name(dir.as_deref()).map(str::to_string)) @@ -339,7 +333,8 @@ fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> { let bundle_id = ctx.get::>(0)?; let path = ctx.get::>(1)?; Ok(crate::flows::is_system(bundle_id.as_deref(), path.as_deref())) - }) + })?; + crate::query::add_functions(conn) } fn read_only_pool(path: &Path, key: Option<&DatabaseKey>) -> Result { @@ -447,8 +442,7 @@ mod tests { assert!(!flows[0].cancelled); assert_eq!((flows[0].stream_kind, flows[0].message_count), (None, 0)); // Failed flows are found without the gRPC status column too. - let failed = - crate::flows::FlowFilter { status: Some("error".into()), ..Default::default() }; + let failed = crate::flows::FlowFilter::search("@is:error"); assert_eq!(crate::flows::list(&db, &failed).unwrap().len(), 0); // Filters by app work without the folder column, and count without the clear mark. @@ -465,8 +459,7 @@ mod tests { .unwrap(); drop(conn); let db = ProxyQueryManager::open_read_only(&path, Some(&test_key())).unwrap(); - let filter = - crate::flows::FlowFilter { app: Some(vec!["curl".into()]), ..Default::default() }; + let filter = crate::flows::FlowFilter::search("@app:curl"); let (page, total, matching) = crate::flows::page(&db, &filter).unwrap(); assert_eq!((page.len(), total, matching), (1, 1, Some(1))); assert_eq!(page[0].number, 1); @@ -513,7 +506,8 @@ mod tests { "flow_messages", "flow_sweep_pending", "flows", - "grpc_schemas" + "grpc_schemas", + "saved_queries" ] ); @@ -532,6 +526,7 @@ mod tests { "grpc_method", "grpc_status", "grpc_message", + "agent_read", ] { assert!(flow_columns.contains(&column.to_string()), "{column} in {flow_columns:?}"); } @@ -599,7 +594,12 @@ mod tests { .unwrap() .map(|v| v.unwrap()) .collect(); - assert_eq!(&applied[6..], ["00000007", "00000008", "00000009", "00000010", "00000011"]); + assert_eq!( + &applied[6..], + [ + "00000007", "00000008", "00000009", "00000010", "00000011", "00000012" + ] + ); let mark_columns = columns(&conn, "flow_clear_mark"); assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]); drop(conn); @@ -718,7 +718,7 @@ mod tests { }; db.with_tx(|c| c.upsert(&tunnel, &yaak_database::UpdateSource::Background).map(|_| ())) .unwrap(); - let filter = crate::flows::FlowFilter { tunnelled: Some(true), ..Default::default() }; + let filter = crate::flows::FlowFilter::search("@is:tunnelled"); let listed = crate::flows::list(&db, &filter).unwrap(); assert_eq!(listed.iter().map(|f| f.id.as_str()).collect::>(), ["fl_tunnel"]); let old = crate::flows::find(&db, "fl_old").unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/flows.rs b/crates-proxy/yaak-proxy-lib/src/flows.rs index 619fddb66..27876ee6c 100644 --- a/crates-proxy/yaak-proxy-lib/src/flows.rs +++ b/crates-proxy/yaak-proxy-lib/src/flows.rs @@ -1,6 +1,7 @@ use crate::bodies; use crate::db::{ProxyQueryManager, Stores}; use crate::models::{Flow, FlowBody}; +use crate::query::{FlowColumns, FlowQuery}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use rusqlite::types::Value; @@ -9,52 +10,19 @@ use ts_rs::TS; use yaak_database::UpsertModelInfo; use yaak_models::blob_manager::BlobManager; -/// Which flows a listing or a HAR export covers. Every field narrows it; none is all of them. +/// Which flows a listing or a HAR export covers: a search, and a page of it. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] pub struct FlowFilter { - /// Search terms, any case, each of which must match the URL (host, path or query), the - /// method, the status (`404`, `40`, `4xx`) or the content type: `stripe post 4xx`. + /// A search in the query language (`crate::query`): `stripe @status:4xx`, + /// `@host:*.github.com -@is:system`. None or blank covers every flow. #[ts(optional)] - pub q: Option, - /// Part of the host, any case: `stripe` matches `api.stripe.com`. - #[ts(optional)] - pub host: Option, - /// Only flows under one of these hosts or paths, as the app's sidebar names them: - /// `api.stripe.com` or `api.stripe.com/v1/charges`, with any port after the host. A host - /// written `*.github.com` is a site: `github.com` and every host below it. - #[ts(optional)] - pub under: Option>, - /// Only flows from one of these apps: by bundle id, by path for a process outside an app - /// bundle, by name in any case (`slack`), or, for a process outside an app, by the folder - /// it ran in (`fieldwork`). - #[ts(optional)] - pub app: Option>, - /// Only flows from a process that ran in one of these working directories: the whole path, - /// or its folder's name in any case (`fieldwork`). With `app`, a program in a folder. - #[ts(optional)] - pub cwd: Option>, - /// Only flows from macOS's own daemons (true), or none of them (false), as [`is_system`] - /// tells them by their executable. - #[ts(optional)] - pub system: Option, - /// Only HTTPS connections tunnelled undecrypted (true), or none of them (false). - #[ts(optional)] - pub tunnelled: Option, - /// Only flows one of these groups covers, each judged on its own: several sidebar rows - /// selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every - /// pairing of their apps and hosts. A group's `after`, `before` and `limit` do not apply. Not - /// carried by a query string. - #[ts(optional)] - pub any: Option>, - /// The method, any case. - #[ts(optional)] - pub method: Option, - /// A status (`404`), a class of them (`4xx`), or `error` for flows that failed, gRPC calls - /// that ended with a status other than OK among them. - #[ts(optional)] - pub status: Option, + pub query: Option, + /// Leaves out macOS's own daemons, as hiding System does, unless the query says + /// `@is:system` itself. + #[ts(optional, as = "Option")] + pub hide_system: bool, /// At most this many flows, the newest. #[ts(optional, type = "number")] pub limit: Option, @@ -69,30 +37,24 @@ pub struct FlowFilter { } impl FlowFilter { - /// The filter from a query string's `q`, `host`, `under`, `app`, `cwd`, `system`, `method`, - /// `status`, `limit`, `after` and `before`. `under`, `app` and `cwd` may repeat. + /// A filter for a search alone. + pub fn search(query: impl Into) -> Self { + Self { query: Some(query.into()), ..Self::default() } + } + + /// The filter from a query string's `query`, `hide_system`, `limit`, `after` and `before`. pub fn from_query(query: &str) -> Result { let mut filter = Self::default(); for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { let value = Some(value.into_owned()).filter(|v| !v.is_empty()); match key.as_ref() { - "q" => filter.q = value, - "host" => filter.host = value, - "under" => filter.under.get_or_insert_default().extend(value), - "app" => filter.app.get_or_insert_default().extend(value), - "cwd" => filter.cwd.get_or_insert_default().extend(value), - "system" => { - filter.system = value - .map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}"))) + "query" => filter.query = value, + "hide_system" => { + filter.hide_system = value + .map(|v| v.parse().map_err(|_| format!("Invalid hide_system {v:?}"))) .transpose()? + .unwrap_or_default() } - "tunnelled" => { - filter.tunnelled = value - .map(|v| v.parse().map_err(|_| format!("Invalid tunnelled {v:?}"))) - .transpose()? - } - "method" => filter.method = value, - "status" => filter.status = value, "after" => filter.after = value, "before" => filter.before = value, "limit" => { @@ -109,30 +71,10 @@ impl FlowFilter { pub fn to_query(&self) -> String { let mut query = url::form_urlencoded::Serializer::new(String::new()); let limit = self.limit.map(|l| l.to_string()); - let system = self.system.map(|s| s.to_string()); - let tunnelled = self.tunnelled.map(|t| t.to_string()); + let hide_system = self.hide_system.then(|| "true".to_string()); for (key, value) in [ - ("q", &self.q), - ("host", &self.host), - ("system", &system), - ("tunnelled", &tunnelled), - ] { - if let Some(value) = value { - query.append_pair(key, value); - } - } - for (key, values) in [ - ("under", &self.under), - ("app", &self.app), - ("cwd", &self.cwd), - ] { - for value in values.iter().flatten() { - query.append_pair(key, value); - } - } - for (key, value) in [ - ("method", &self.method), - ("status", &self.status), + ("query", &self.query), + ("hide_system", &hide_system), ("limit", &limit), ("after", &self.after), ("before", &self.before), @@ -144,166 +86,21 @@ impl FlowFilter { query.finish() } + /// The search, prepared to run now. + pub fn flow_query(&self) -> FlowQuery { + FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hide_system) + } + /// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows. pub fn narrows(&self) -> bool { - let given = |v: &Option| v.as_deref().is_some_and(|v| !v.trim().is_empty()); - given(&self.q) - || given(&self.host) - || given(&self.method) - || given(&self.status) - || self.under.is_some() - || self.app.is_some() - || self.cwd.is_some() - || self.system.is_some() - || self.tunnelled.is_some() - || self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows)) + !self.flow_query().is_empty() } - /// The SQL condition for every field but `after`, `before` and `limit`, and its parameters, - /// for the database `conn` reads, which may be from before the folder or gRPC columns, when - /// read without migrating. + /// The SQL condition for the search, and its parameters, for the database `conn` reads, + /// which may be from before later columns, when read without migrating. fn condition_in(&self, conn: &rusqlite::Connection) -> (String, Vec) { - let columns = Columns { - cwd: has_column(conn, "client_cwd"), - grpc: has_column(conn, "grpc_status"), - tunnelled: has_column(conn, "tunnelled"), - }; - self.condition(columns) + self.flow_query().sql(&FlowColumns::of(conn)) } - - /// [`Self::condition_in`], told which of the later columns the database has. - fn condition(&self, columns: Columns) -> (String, Vec) { - let Columns { cwd: has_cwd, grpc, tunnelled } = columns; - let mut sql = String::from("1"); - let mut params: Vec = Vec::new(); - for term in self.q.iter().flat_map(|q| q.split_whitespace()) { - let term = term.to_lowercase(); - sql.push_str( - " AND (instr(lower(url), ?) > 0 OR lower(method) = ? \ - OR instr(lower(content_type), ?) > 0", - ); - params.extend([ - term.clone().into(), - term.clone().into(), - term.clone().into(), - ]); - if let Some(status) = status_condition(&term, &mut params) { - sql.push_str(" OR "); - sql.push_str(&status); - } - sql.push(')'); - } - if let Some(host) = self.host.as_deref().filter(|h| !h.is_empty()) { - sql.push_str(&format!(" AND instr(lower({HOST_SQL}), ?) > 0")); - params.push(host.to_lowercase().into()); - } - if let Some(under) = &self.under { - // The node itself, or anything below it, by the sidebar's rules. - let mut any = Vec::new(); - for node in under { - if let Some(site) = site_of_node(node) { - any.push("under_site(url, ?)"); - params.push(site.into()); - continue; - } - let node = normalize_node(node); - any.push("(sidebar_path(url) = ? OR substr(sidebar_path(url), 1, ?) = ?)"); - let below = format!("{node}/"); - params.extend([ - node.into(), - (below.chars().count() as i64).into(), - below.into(), - ]); - } - let any = if any.is_empty() { "0".to_string() } else { any.join(" OR ") }; - sql.push_str(&format!(" AND ({any})")); - } - if let Some(apps) = &self.app { - let marks = vec!["?"; apps.len()].join(", "); - sql.push_str(&format!( - " AND (coalesce(client_bundle_id, client_path) IN ({marks}) \ - OR lower(client_name) IN ({marks})" - )); - params.extend(apps.iter().map(|a| a.clone().into())); - params.extend(apps.iter().map(|a| a.to_lowercase().into())); - if has_cwd { - sql.push_str(&format!( - " OR (client_bundle_id IS NULL AND lower(folder_name(client_cwd)) IN ({marks}))" - )); - params.extend(apps.iter().map(|a| a.to_lowercase().into())); - } - sql.push(')'); - } - if let Some(cwds) = &self.cwd { - if has_cwd && !cwds.is_empty() { - let marks = vec!["?"; cwds.len()].join(", "); - sql.push_str(&format!( - " AND (client_cwd IN ({marks}) OR lower(folder_name(client_cwd)) IN ({marks}))" - )); - params.extend(cwds.iter().map(|c| c.clone().into())); - params.extend(cwds.iter().map(|c| c.to_lowercase().into())); - } else { - // None, or a database from before the column, which has no folders. - sql.push_str(" AND 0"); - } - } - match self.system { - Some(true) => sql.push_str(" AND is_system(client_bundle_id, client_path)"), - Some(false) => sql.push_str(" AND NOT is_system(client_bundle_id, client_path)"), - None => {} - } - match (self.tunnelled, tunnelled) { - (Some(true), true) => sql.push_str(" AND tunnelled"), - (Some(false), true) => sql.push_str(" AND NOT tunnelled"), - // A database from before tunnelled flows has none. - (Some(true), false) => sql.push_str(" AND 0"), - _ => {} - } - // Each group whole, then any of them. - if let Some(groups) = self.any.as_ref().filter(|g| !g.is_empty()) { - let mut any = Vec::new(); - for group in groups { - let (condition, group_params) = group.condition(columns); - any.push(format!("({condition})")); - params.extend(group_params); - } - sql.push_str(&format!(" AND ({})", any.join(" OR "))); - } - if let Some(method) = self.method.as_deref().filter(|m| !m.is_empty()) { - sql.push_str(" AND lower(method) = ?"); - params.push(method.to_lowercase().into()); - } - match self.status.as_deref().map(str::trim) { - None | Some("") => {} - // A gRPC call that ended with a status other than OK failed too. - Some(s) if s.eq_ignore_ascii_case("error") && grpc => { - sql.push_str(" AND (error IS NOT NULL OR coalesce(grpc_status, 0) != 0)") - } - Some(s) if s.eq_ignore_ascii_case("error") => sql.push_str(" AND error IS NOT NULL"), - // A whole status or a class, not the start of one as a search term may be. - Some(s) if s.len() == 3 => match status_condition(&s.to_ascii_lowercase(), &mut params) - { - Some(status) => { - sql.push_str(" AND "); - sql.push_str(&status); - } - None => sql.push_str(" AND 0"), - }, - Some(_) => sql.push_str(" AND 0"), - } - (sql, params) - } -} - -/// Which of the columns later migrations added a database has, as one opened read-only before -/// they ran may not. -#[derive(Clone, Copy)] -struct Columns { - /// The folder a program ran in. - cwd: bool, - /// gRPC statuses. - grpc: bool, - tunnelled: bool, } /// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`. @@ -341,38 +138,6 @@ pub fn sidebar_path(url: &str) -> String { key } -/// A sidebar node as `sidebar_path` writes it, its host lowercased. -fn normalize_node(node: &str) -> String { - match node.split_once('/') { - Some((host, path)) => format!("{}/{path}", host.to_ascii_lowercase()), - None => node.to_ascii_lowercase(), - } -} - -/// The site a sidebar node names, `github.com` for `*.github.com`, lowercased. None for a host or -/// a path. -fn site_of_node(node: &str) -> Option { - let site = node.strip_prefix("*.")?; - (!site.is_empty() && !site.contains('/')).then(|| site.to_ascii_lowercase()) -} - -/// `url`'s host, without its port, is `site` or a host below it: `api.github.com` is under -/// `github.com`. -pub fn under_site(url: &str, site: &str) -> bool { - let path = sidebar_path(url); - let host = path.split('/').next().unwrap_or_default(); - let host = strip_port(host); - host == site || host.strip_suffix(site).is_some_and(|rest| rest.ends_with('.')) -} - -/// `host` without a `:port` after it, keeping an IPv6 address's brackets. -fn strip_port(host: &str) -> &str { - if host.starts_with('[') { - return host.find(']').map_or(host, |end| &host[..=end]); - } - host.rsplit_once(':').map_or(host, |(h, _)| h) -} - /// The last part of a directory's path, `fieldwork` for `/Users/me/src/fieldwork`. None for the /// root, where daemons run, or no path. pub fn folder_name(path: Option<&str>) -> Option<&str> { @@ -418,26 +183,6 @@ pub fn is_system(bundle_id: Option<&str>, path: Option<&str>) -> bool { SYSTEM_DIRS.iter().any(|dir| path.starts_with(dir)) || SYSTEM_PROGRAMS.contains(&name) } -/// The SQL condition for a status term: `404` exactly, `40` as the start of one, or a class -/// such as `4xx`. None when the term is not one. -fn status_condition(term: &str, params: &mut Vec) -> Option { - if let Some(class) = term.strip_suffix("xx") - && let Ok(class @ 1..=9) = class.parse::() - { - params.push(class.into()); - return Some("status / 100 = ?".into()); - } - if term.is_empty() || term.len() > 3 || !term.chars().all(|c| c.is_ascii_digit()) { - return None; - } - if term.len() == 3 { - params.push(term.parse::().ok()?.into()); - return Some("status = ?".into()); - } - params.push(format!("{term}%").into()); - Some("CAST(status AS TEXT) LIKE ?".into()) -} - /// The flows `filter` covers, newest first: by start, then by id. Reads the table in that order /// through its index and stops at the limit, so a page of a common match costs the same however /// many flows there are. @@ -462,13 +207,6 @@ pub fn page( }) } -/// The database has the gRPC status column, added by a later migration. -/// The flows table has `column`: a database opened read-only before its migrations have run may -/// not. -fn has_column(conn: &rusqlite::Connection, column: &str) -> bool { - conn.prepare(&format!("SELECT {column} FROM flows LIMIT 0")).is_ok() -} - fn list_in(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result, String> { let (condition, mut params) = filter.condition_in(conn); // The rowid, for the flow's number. @@ -511,6 +249,40 @@ fn count_matching(conn: &rusqlite::Connection, filter: &FlowFilter) -> Result Result, String> { + db.with_conn(|c| { + let tx = c.conn().resolve().unchecked_transaction().map_err(|e| e.to_string())?; + let counts = filters.iter().map(|f| count_matching(&tx, f)).collect::>()?; + tx.commit().map_err(|e| e.to_string())?; + Ok(counts) + }) +} + +/// The flows of `ids` that `filter`'s search covers, in any order. Cleared ones are left out. +pub fn matching_ids( + db: &ProxyQueryManager, + filter: &FlowFilter, + ids: &[String], +) -> Result, String> { + if ids.is_empty() { + return Ok(Vec::new()); + } + db.with_conn(|c| { + let conn = c.conn().resolve(); + let (condition, mut params) = filter.condition_in(conn); + let marks = vec!["?"; ids.len()].join(", "); + let mut sql = format!("SELECT id FROM flows WHERE ({condition}) AND id IN ({marks})"); + params.extend(ids.iter().map(|id| Value::from(id.clone()))); + push_clear_mark(conn, &mut sql, &mut params); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params), |r| r.get(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + }) +} + /// Leaves out cleared flows, when a clear is pending; otherwise adds nothing, so SQLite keeps its /// cheapest plan. fn push_clear_mark(conn: &rusqlite::Connection, sql: &mut String, params: &mut Vec) { @@ -1031,6 +803,13 @@ mod tests { list(db, &filter).unwrap().into_iter().map(|f| f.url).collect() } + /// The urls a search covers, sorted. + fn found(db: &ProxyQueryManager, query: &str) -> Vec { + let mut found = urls(db, FlowFilter::search(query)); + found.sort(); + found + } + #[test] fn filters() { let (_dir, db) = db_of(vec![ @@ -1038,18 +817,20 @@ mod tests { flow("http://example.com/missing", "GET", Some(404), None), flow("https://down.example.com/", "GET", None, Some("refused")), ]); - let by = |filter: FlowFilter| urls(&db, filter).len(); - assert_eq!(by(FlowFilter::default()), 3); - assert_eq!(by(FlowFilter { host: Some("STRIPE".into()), ..Default::default() }), 1); - assert_eq!(by(FlowFilter { host: Some("example.com".into()), ..Default::default() }), 2); - assert_eq!(by(FlowFilter { host: Some("charges".into()), ..Default::default() }), 0); - assert_eq!(by(FlowFilter { host: Some("8443".into()), ..Default::default() }), 1); - assert_eq!(by(FlowFilter { method: Some("get".into()), ..Default::default() }), 2); - assert_eq!(by(FlowFilter { status: Some("404".into()), ..Default::default() }), 1); - assert_eq!(by(FlowFilter { status: Some("2xx".into()), ..Default::default() }), 1); - assert_eq!(by(FlowFilter { status: Some("5XX".into()), ..Default::default() }), 0); - assert_eq!(by(FlowFilter { status: Some("40".into()), ..Default::default() }), 0); - assert_eq!(by(FlowFilter { status: Some("error".into()), ..Default::default() }), 1); + let by = |query: &str| urls(&db, FlowFilter::search(query)).len(); + assert_eq!(urls(&db, FlowFilter::default()).len(), 3); + // A host whole, with or without its port; a part of one with wildcards. + assert_eq!(by("@host:API.STRIPE.COM"), 1); + assert_eq!(by("@host:api.stripe.com:8443"), 1); + assert_eq!(by("@host:stripe"), 0); + assert_eq!(by("@host:*stripe*"), 1); + assert_eq!(by("@host:*.example.com"), 2); + assert_eq!(by("@method:get"), 2); + assert_eq!(by("@status:404"), 1); + assert_eq!(by("@status:2xx"), 1); + assert_eq!(by("@status:5XX"), 0); + assert_eq!(by("@status:40"), 0); + assert_eq!(by("@is:error"), 1); } #[test] @@ -1067,12 +848,7 @@ mod tests { }, flow("http://localhost:3000/health", "HEAD", Some(503), None), ]); - let search = |q: &str| { - let filter = FlowFilter { q: Some(q.into()), ..Default::default() }; - let mut found = urls(&db, filter); - found.sort(); - found - }; + let search = |q: &str| found(&db, q); let stripe = "https://api.stripe.com/v1/charges?customer=cus_9"; let github = "https://api.github.com/repos/yaak"; let logo = "https://images.ctfassets.net/logo.png"; @@ -1081,28 +857,28 @@ mod tests { assert_eq!(search("STRIPE"), vec![stripe]); assert_eq!(search("/repos/"), vec![github]); assert_eq!(search("cus_9"), vec![stripe]); - // The method, whole. + // The method and the status, any part of them. assert_eq!(search("head"), vec![health]); - assert_eq!(search("ead"), Vec::<&str>::new()); - // A status, its start, or its class. + assert_eq!(search("ead"), vec![health]); assert_eq!(search("404"), vec![github]); assert_eq!(search("50"), vec![health]); - assert_eq!(search("2xx"), vec![stripe, logo]); // The content type. assert_eq!(search("png"), vec![logo]); assert_eq!(search("json"), vec![github, stripe]); // Every term must match, each anywhere. assert_eq!(search("json get"), vec![github]); - assert_eq!(search(" json 2xx "), vec![stripe]); - assert_eq!(search("json 5xx"), Vec::<&str>::new()); + assert_eq!(search(" json @status:2xx "), vec![stripe]); + assert_eq!(search("json @status:5xx"), Vec::<&str>::new()); // `%` and `_` are plain characters, not patterns. assert_eq!(search("%"), Vec::<&str>::new()); assert_eq!(search("cus_"), vec![stripe]); assert_eq!(search(""), vec![health, github, stripe, logo]); } + /// The terms a sidebar row writes cover what is under it: a host, with any port, and a + /// path with what is below it. #[test] - fn under_matches_a_host_or_a_path_as_the_sidebar_does() { + fn hosts_and_paths_as_the_sidebar_writes_them() { let (_dir, db) = db_of(vec![ flow("https://api.stripe.com/v1/charges", "GET", Some(200), None), flow("https://api.stripe.com/v1/charges/ch_1?expand=x", "GET", Some(200), None), @@ -1110,29 +886,38 @@ mod tests { flow("https://api.stripe.com:8443/v1/charges", "GET", Some(200), None), flow("http://api.stripe.com", "GET", Some(200), None), flow("https://api.stripe.community/", "GET", Some(200), None), - flow("https://example.com/v1//users", "GET", Some(200), None), flow("http://x.com:80/a", "GET", Some(200), None), flow("https://x.com:443/a", "GET", Some(200), None), + flow("https://example.com/v1//users", "GET", Some(200), None), ]); - let under = |nodes: &[&str]| { - let nodes = nodes.iter().map(|n| n.to_string()).collect(); - urls(&db, FlowFilter { under: Some(nodes), ..Default::default() }).len() - }; - // A host on another port is a node of its own, as in the sidebar. - assert_eq!(under(&["api.stripe.com"]), 4); - assert_eq!(under(&["api.stripe.com:8443"]), 1); - assert_eq!(under(&["api.stripe.com/v1/charges"]), 2); - // Empty segments and default ports are left out, as the sidebar leaves them. - assert_eq!(under(&["example.com/v1/users"]), 1); - assert_eq!(under(&["x.com/a"]), 2); - assert_eq!(under(&["api.stripe.com/v1/charges", "example.com"]), 3); - assert_eq!(under(&[]), 0); - // The sidebar lowercases hosts; paths keep their case. - assert_eq!(under(&["API.stripe.com/v1/charges"]), 2); - assert_eq!(under(&["api.stripe.com/V1/charges"]), 0); + let count = |query: &str| found(&db, query).len(); + assert_eq!(count("@host:api.stripe.com"), 5); + assert_eq!(count("@host:api.stripe.com:8443"), 1); + assert_eq!(count("@host:api.stripe.com @path:/v1/charges/*"), 3); + assert_eq!(count("@host:x.com @path:/a"), 2); + assert_eq!(count("@host:api.stripe.com @path:/v1/charges/* OR @host:x.com"), 5); + assert_eq!(count("@host:API.stripe.com @path:/V1/charges/*"), 3); + // A path with empty segments is found by the sidebar's row for it. + assert_eq!(count("@host:example.com @path:/v1/users/*"), 1); } /// The cases `lib/search.test.ts` checks `sidebarPath` against, so the two agree. + /// A search with `OR` at its top still pages and leaves out cleared flows. + #[test] + fn a_top_level_or_keeps_to_the_page_and_the_clear() { + let (_dir, db) = db_of(vec![ + flow("https://a.com/1", "GET", Some(200), None), + flow("https://a.com/2", "GET", Some(404), None), + flow("https://a.com/3", "GET", Some(200), None), + ]); + let query = "@status:200 OR @status:404"; + let before = FlowFilter { before: Some("fl_001".into()), ..FlowFilter::search(query) }; + assert_eq!(urls(&db, before), vec!["https://a.com/1"]); + clear(&db).unwrap(); + assert!(urls(&db, FlowFilter::search(query)).is_empty()); + assert_eq!(page(&db, &FlowFilter::search(query)).unwrap().2, Some(0)); + } + #[test] fn sidebar_path_rules() { for (url, path) in [ @@ -1163,18 +948,15 @@ mod tests { from(None, "/usr/bin/curl"), flow("https://example.com/", "GET", Some(200), None), ]); - let app = |apps: &[&str]| { - let apps = apps.iter().map(|a| a.to_string()).collect(); - urls(&db, FlowFilter { app: Some(apps), ..Default::default() }).len() - }; - assert_eq!(app(&["com.google.Chrome"]), 2); - assert_eq!(app(&["/usr/bin/curl"]), 1); - assert_eq!(app(&["/Applications/Google Chrome.app"]), 0); - assert_eq!(app(&["com.google.Chrome", "/usr/bin/curl"]), 3); + let app = |query: &str| found(&db, query).len(); + assert_eq!(app("@app:com.google.Chrome"), 2); + assert_eq!(app("@app:/usr/bin/curl"), 1); + assert_eq!(app("@app:\"/Applications/Google Chrome.app\""), 0); + assert_eq!(app("@app:com.google.Chrome OR @app:/usr/bin/curl"), 3); } - /// A program in a folder: its working directory by the whole path, or its folder's name in - /// any case, along with whatever else the filter asks. + /// A program in a folder: its working directory by the whole path, or its folder's name, + /// any case, along with whatever else the search asks. #[test] fn cwd_matches_a_path_or_a_folder_name() { let node_in = |cwd: Option<&str>| Flow { @@ -1191,22 +973,14 @@ mod tests { Flow { client_cwd: Some("/tmp/fieldwork".into()), ..node_in(None) }, Flow { client_path: Some("/usr/bin/curl".into()), ..node_in(Some("/tmp/ledger")) }, ]); - let count = |app: Option<&str>, cwds: &[&str]| { - let cwd = Some(cwds.iter().map(|c| c.to_string()).collect()); - let app = app.map(|a| vec![a.to_string()]); - urls(&db, FlowFilter { app, cwd, ..Default::default() }).len() - }; - assert_eq!(count(None, &["/Users/ada/Workspace/fieldwork"]), 2, "the whole path"); - assert_eq!(count(None, &["/users/ada/workspace/fieldwork"]), 0, "a path exactly"); - assert_eq!(count(None, &["FieldWork"]), 3, "a folder name, any case"); - assert_eq!(count(None, &["fieldwork", "ledger"]), 5, "any of them"); - assert_eq!(count(Some("/usr/local/bin/node"), &["ledger"]), 1, "and the program"); - assert_eq!(count(Some("/usr/bin/curl"), &["ledger"]), 1); - assert_eq!(count(None, &[]), 0); - let query = FlowFilter { cwd: Some(vec!["a".into(), "/b".into()]), ..Default::default() }; - assert_eq!(query.to_query(), "cwd=a&cwd=%2Fb"); - assert_eq!(FlowFilter::from_query(&query.to_query()).unwrap(), query); - assert!(query.narrows()); + let count = |query: &str| found(&db, query).len(); + assert_eq!(count("@cwd:/Users/ada/Workspace/fieldwork"), 2, "the whole path"); + assert_eq!(count("@cwd:/users/ada/workspace/fieldwork"), 2, "any case"); + assert_eq!(count("@cwd:FieldWork"), 3, "a folder name"); + assert_eq!(count("@cwd:fieldwork OR @cwd:ledger"), 5, "any of them"); + assert_eq!(count("@app:/usr/local/bin/node @cwd:ledger"), 1, "and the program"); + assert_eq!(count("@app:/usr/bin/curl @cwd:ledger"), 1); + assert_eq!(count("@cwd:work"), 0, "whole"); } #[test] @@ -1221,7 +995,7 @@ mod tests { .collect(), ); let search = |before: Option| FlowFilter { - q: Some("post".into()), + query: Some("post".into()), before, limit: Some(4), ..Default::default() @@ -1245,8 +1019,7 @@ mod tests { let (_dir, db) = db_of(vec![]); let plan: Vec = db.with_conn(|c| { let conn = c.conn().resolve(); - let (condition, params) = - FlowFilter { q: Some("stripe".into()), ..Default::default() }.condition_in(conn); + let (condition, params) = FlowFilter::search("stripe @status:>=400").condition_in(conn); let sql = format!( "EXPLAIN QUERY PLAN SELECT * FROM flows WHERE {condition} \ ORDER BY started_at DESC, id DESC LIMIT 500" @@ -1322,7 +1095,7 @@ mod tests { // The limit counts flows the filter keeps. let ok = FlowFilter { before: Some("fl_e".into()), - status: Some("2xx".into()), + query: Some("@status:2xx".into()), limit: Some(2), ..Default::default() }; @@ -1380,7 +1153,7 @@ mod tests { write_flow("fl_d"); assert_eq!(ids(&FlowFilter::default()), vec!["fl_d"]); assert_eq!(find(&db, "fl_d").map(|f| f.number), Some(4)); - let narrowed = FlowFilter { q: Some("example".into()), ..Default::default() }; + let narrowed = FlowFilter::search("example"); assert_eq!(page(&db, &narrowed).unwrap().1, 1); // Bodies that fail to delete keep their flows, to delete again. @@ -1540,49 +1313,39 @@ mod tests { ids.sort(); ids }; - let app = |a: &str| FlowFilter { app: Some(vec![a.into()]), ..Default::default() }; - assert_eq!(ids(app("slack")), vec!["fl_slack"]); - assert_eq!(ids(app("com.tinyspeck.slackmacgap")), vec!["fl_slack"]); - assert_eq!(ids(app("Fieldwork")), vec!["fl_node"]); - assert_eq!(ids(app("/opt/homebrew/bin/node")), vec!["fl_node"]); - let under = |n: &str| FlowFilter { under: Some(vec![n.into()]), ..Default::default() }; - assert_eq!(ids(under("*.github.com")), vec!["fl_daemon", "fl_node"]); - assert_eq!(ids(under("api.github.com")), vec!["fl_node"]); - let system = |s| FlowFilter { system: Some(s), ..Default::default() }; - assert_eq!(ids(system(true)), vec!["fl_daemon"]); - assert_eq!(ids(system(false)), vec!["fl_node", "fl_none", "fl_slack"]); + let ids = |query: &str| ids(FlowFilter::search(query)); + assert_eq!(ids("@app:slack"), vec!["fl_slack"]); + assert_eq!(ids("@app:com.tinyspeck.slackmacgap"), vec!["fl_slack"]); + assert_eq!(ids("@app:Fieldwork"), vec!["fl_node"]); + assert_eq!(ids("@app:/opt/homebrew/bin/node"), vec!["fl_node"]); + assert_eq!(ids("@host:*.github.com"), vec!["fl_daemon", "fl_node"]); + assert_eq!(ids("@host:api.github.com"), vec!["fl_node"]); + assert_eq!(ids("@is:system"), vec!["fl_daemon"]); + assert_eq!(ids("-@is:system"), vec!["fl_node", "fl_none", "fl_slack"]); // Groups, as two sidebar rows under different apps write them, each judged whole: // flattened, the apps and hosts would pair across rows and match both flows. - let group = |a: &str, n: &str| FlowFilter { - app: Some(vec![a.into()]), - under: Some(vec![n.into()]), - ..Default::default() - }; - let crossed = FlowFilter { - any: Some(vec![ - group("fieldwork", "github.com"), - group("nsurlsessiond", "api.github.com"), - ]), - ..Default::default() - }; - assert_eq!(ids(crossed.clone()), Vec::::new()); - assert_eq!(page(&db, &crossed).unwrap().2, Some(0)); - let flattened = FlowFilter { - app: Some(vec!["fieldwork".into(), "nsurlsessiond".into()]), - under: Some(vec!["github.com".into(), "api.github.com".into()]), - ..Default::default() - }; + let crossed = "@app:fieldwork @host:github.com OR @app:nsurlsessiond @host:api.github.com"; + assert_eq!(ids(crossed), Vec::::new()); + assert_eq!(page(&db, &FlowFilter::search(crossed)).unwrap().2, Some(0)); + let flattened = "(@app:fieldwork OR @app:nsurlsessiond) \ + (@host:github.com OR @host:api.github.com)"; assert_eq!(ids(flattened), vec!["fl_daemon", "fl_node"]); - let either = FlowFilter { - any: Some(vec![ - group("fieldwork", "api.github.com"), - FlowFilter { app: Some(vec!["slack".into()]), ..Default::default() }, - ]), - q: Some("https".into()), - ..Default::default() - }; + let either = "(@app:fieldwork @host:api.github.com OR @app:slack) https"; assert_eq!(ids(either), vec!["fl_node", "fl_slack"]); + + // Hiding System leaves out its daemons, unless the search names them. + let hidden = |query: &str| { + let filter = FlowFilter { hide_system: true, ..FlowFilter::search(query) }; + let mut ids: Vec = + list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect(); + ids.sort(); + ids + }; + assert_eq!(hidden(""), vec!["fl_node", "fl_none", "fl_slack"]); + assert_eq!(hidden("@host:*.github.com"), vec!["fl_node"]); + assert_eq!(hidden("@is:system"), vec!["fl_daemon"]); + assert_eq!(hidden("@host:*.github.com OR @is:SYSTEM"), vec!["fl_daemon", "fl_node"]); } #[test] @@ -1603,35 +1366,24 @@ mod tests { assert!(!is_system(None, None)); assert_eq!(folder_name(Some("/Users/me/src/fieldwork/")), Some("fieldwork")); assert_eq!(folder_name(Some("/")), None); - assert!(under_site("https://api.github.com:8443/x", "github.com")); - assert!(!under_site("https://notgithub.com/", "github.com")); } #[test] fn query_round_trip() { let filter = FlowFilter { - q: Some("post json".into()), - host: Some("api.stripe.com".into()), - under: Some(vec!["api.stripe.com/v1".into(), "example.com".into()]), - app: Some(vec!["com.google.Chrome".into()]), - status: Some("4xx".into()), + query: Some("post @status:4xx".into()), + hide_system: true, limit: Some(20), ..Default::default() }; - assert_eq!( - filter.to_query(), - "q=post+json&host=api.stripe.com&under=api.stripe.com%2Fv1&under=example.com\ - &app=com.google.Chrome&status=4xx&limit=20" - ); + assert_eq!(filter.to_query(), "query=post+%40status%3A4xx&hide_system=true&limit=20"); assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter); assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default()); assert!(FlowFilter::from_query("limit=lots").is_err()); - - let tunnelled = FlowFilter { tunnelled: Some(true), ..Default::default() }; - assert_eq!(tunnelled.to_query(), "tunnelled=true"); - assert_eq!(FlowFilter::from_query("tunnelled=true").unwrap(), tunnelled); - assert!(tunnelled.narrows()); - assert!(FlowFilter::from_query("tunnelled=maybe").is_err()); + assert!(FlowFilter::from_query("hide_system=maybe").is_err()); + assert!(filter.narrows()); + assert!(FlowFilter { hide_system: true, ..Default::default() }.narrows()); + assert!(!FlowFilter::search(" ").narrows()); } #[test] diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 80e16fb68..fc935c80d 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -14,7 +14,9 @@ pub mod helper; pub mod macos; pub mod messages; pub mod models; +pub mod query; pub mod save; +pub mod saved_queries; pub mod settings; pub mod terminal; @@ -47,7 +49,7 @@ use std::time::{Duration, SystemTime}; use ts_rs::TS; pub use yaak_crypto::MasterKeySource; use yaak_crypto::data_key::DataKey; -use yaak_database::{ModelChangeEvent, UpdateSource, UpsertModelInfo}; +use yaak_database::{UpdateSource, UpsertModelInfo}; pub use yaak_grpc::GrpcConfig; use yaak_models::blob_manager::{BlobManager, BodyChunkWriter}; use yaak_proxy::{CaStore, KeyCipher, ProxyConfig, ProxyEvent, ProxyHandle}; @@ -107,6 +109,8 @@ pub struct ProxyCtx { pub events: RpcEventEmitter, /// gRPC schemas as decoded, and the protoc that compiles attached protos. pub grpc: GrpcState, + /// The search the app shows, which written flows' events are matched against. + live_search: Arc, } impl ProxyCtx { @@ -148,6 +152,7 @@ impl ProxyCtx { sweeper: Default::default(), events, grpc: GrpcState::default(), + live_search: Default::default(), }; if db::needs_encrypting(data_dir) { let unlocking = ctx.clone(); @@ -418,6 +423,7 @@ impl ProxyCtx { db: self.db()?, blobs: self.blobs()?, events: self.events.clone(), + live_search: self.live_search.clone(), attribution, recording: self.recording.clone(), finished: Default::default(), @@ -1118,16 +1124,38 @@ impl ProxyCtx { } let (flow, created) = tx.upsert(&flow, &UpdateSource::Background)?; // Sent before the commit, so it cannot reach the app after a clear that follows. - self.events.emit( - "model_write", - &ModelPayload { model: flow, change: ModelChangeEvent::Upsert { created } }, - ); + self.events.emit("model_write", &self.live_search.payload(flow, created)); Ok::<_, yaak_database::Error>(()) }) .map_err(|e| format!("Failed to record the saved request on the flow: {e}"))?; Ok(saved) } + /// Records that an agent read the flow through the control API, for `@is:read`, and tells + /// the app. Only the first read writes anything. + pub fn note_agent_read(&self, flow_id: &str) { + let noted = self.db().map_err(String::from).and_then(|db| { + db.with_tx(|tx| { + let conn = tx.conn().resolve(); + let changed = conn.execute( + "UPDATE flows SET agent_read = TRUE WHERE id = ? AND NOT agent_read", + [flow_id], + )?; + // Sent before the commit, as every flow's event is. + if changed > 0 + && let Some(flow) = flows::find_in(conn, flow_id) + { + self.events.emit("model_write", &self.live_search.payload(flow, false)); + } + Ok::<_, yaak_database::Error>(()) + }) + .map_err(|e| e.to_string()) + }); + if let Err(e) = noted { + warn!("Failed to note that an agent read flow {flow_id}: {e}"); + } + } + /// Clears every flow at once and tells the app so its list empties, then deletes them and /// their bodies in the background. Returns how many there were. pub fn clear_flows(&self) -> Result { @@ -1385,6 +1413,91 @@ pub struct ListFlowsResponse { pub matching: Option, } +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct QuerySchemaRequest {} + +/// The search's fields, for the search box's highlighting and completion. +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct QuerySchemaResponse { + #[ts(type = "import(\"@yaakapp-internal/query\").Schema")] + pub schema: serde_json::Value, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ValidateQueryRequest { + pub query: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct QueryValuesRequest { + /// A field's path: `host`, `header` for header names, `header.accept` for a header's values. + pub path: String, +} + +/// The search the app shows, which flows written from now on are matched against, their events +/// naming `key`. No query stops it. +#[derive(Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct WatchQueryRequest { + pub key: String, + #[ts(optional)] + pub query: Option, + #[serde(default)] + #[ts(optional, as = "Option")] + pub hide_system: bool, +} + +/// Which of some flows a search covers, for flows whose events were matched against another. +#[derive(Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct MatchFlowsRequest { + pub filter: flows::FlowFilter, + pub ids: Vec, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ListSavedQueriesRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CreateSavedQueryRequest { + pub name: String, + pub query: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct RenameSavedQueryRequest { + pub id: String, + pub name: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct DeleteSavedQueryRequest { + pub id: String, +} + +#[derive(Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CountSavedQueriesRequest { + #[serde(default)] + #[ts(optional, as = "Option")] + pub hide_system: bool, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SavedQueriesChangedPayload {} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ClearFlowsRequest {} @@ -2326,6 +2439,73 @@ async fn clear_flows(ctx: ProxyCtx, _req: ClearFlowsRequest) -> Result Result { + let schema = serde_json::to_value(query::schema()).map_err(rpc_error)?; + Ok(QuerySchemaResponse { schema }) +} + +fn validate_query( + _ctx: &ProxyCtx, + req: ValidateQueryRequest, +) -> Result, RpcError> { + Ok(query::diagnostics(&req.query)) +} + +async fn query_values(ctx: ProxyCtx, req: QueryValuesRequest) -> Result, RpcError> { + blocking(move || query::seen_values(&ctx.db()?, &req.path)).await +} + +fn watch_query(ctx: &ProxyCtx, req: WatchQueryRequest) -> Result { + let watched = req.query.filter(|q| !q.trim().is_empty() || req.hide_system); + ctx.live_search.watch(watched.map(|q| (req.key, q, req.hide_system))); + Ok(true) +} + +async fn match_flows(ctx: ProxyCtx, req: MatchFlowsRequest) -> Result, RpcError> { + blocking(move || flows::matching_ids(&ctx.db()?, &req.filter, &req.ids)).await +} + +fn list_saved_queries( + ctx: &ProxyCtx, + _req: ListSavedQueriesRequest, +) -> Result, RpcError> { + saved_queries::list(&ctx.db()?).map_err(rpc_error) +} + +fn create_saved_query( + ctx: &ProxyCtx, + req: CreateSavedQueryRequest, +) -> Result { + let saved = saved_queries::create(&ctx.db()?, &req.name, &req.query).map_err(rpc_error)?; + ctx.events.emit("saved_queries_changed", &SavedQueriesChangedPayload {}); + Ok(saved) +} + +fn rename_saved_query( + ctx: &ProxyCtx, + req: RenameSavedQueryRequest, +) -> Result { + let saved = saved_queries::rename(&ctx.db()?, &req.id, &req.name).map_err(rpc_error)?; + ctx.events.emit("saved_queries_changed", &SavedQueriesChangedPayload {}); + Ok(saved) +} + +fn delete_saved_query(ctx: &ProxyCtx, req: DeleteSavedQueryRequest) -> Result { + let deleted = saved_queries::delete(&ctx.db()?, &req.id).map_err(rpc_error)?; + ctx.events.emit("saved_queries_changed", &SavedQueriesChangedPayload {}); + Ok(deleted) +} + +async fn count_saved_queries( + ctx: ProxyCtx, + req: CountSavedQueriesRequest, +) -> Result, RpcError> { + blocking(move || saved_queries::counts(&ctx.db()?, req.hide_system)).await +} + // -- Event loop -- /// Why the engine cannot be taken over from another app. @@ -2429,6 +2609,8 @@ struct FlowSink { db: ProxyQueryManager, blobs: BlobManager, events: RpcEventEmitter, + /// The search the app shows, which each flow written is matched against. + live_search: Arc, attribution: Attribution, recording: Arc>, /// Flows that have ended, waiting to be written together. @@ -3076,10 +3258,7 @@ impl FlowSink { let (saved, created) = ctx.upsert(&flow, &UpdateSource::Background)?; // Sent while this holds the writer, before the commit, so a clear's event, sent after // its own commit, always comes after it. - self.events.emit( - "model_write", - &ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } }, - ); + self.events.emit("model_write", &self.live_search.payload(saved, created)); Ok(true) } @@ -3142,6 +3321,13 @@ define_rpc! { get_message_payload(GetMessagePayloadRequest) -> GetMessagePayloadResponse, list_actions(ListActionsRequest) -> ListActionsResponse, list_flows(flows::FlowFilter) -> ListFlowsResponse, + query_schema(QuerySchemaRequest) -> QuerySchemaResponse, + validate_query(ValidateQueryRequest) -> Vec, + watch_query(WatchQueryRequest) -> bool, + list_saved_queries(ListSavedQueriesRequest) -> Vec, + create_saved_query(CreateSavedQueryRequest) -> saved_queries::SavedQuery, + rename_saved_query(RenameSavedQueryRequest) -> saved_queries::SavedQuery, + delete_saved_query(DeleteSavedQueryRequest) -> bool, list_grpc_schemas(ListGrpcSchemasRequest) -> Vec, list_messages(ListMessagesRequest) -> ListMessagesResponse, remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool, @@ -3179,6 +3365,9 @@ define_rpc! { get_storage_size(GetStorageSizeRequest) -> GetStorageSizeResponse, reset_captured_data(ResetCapturedDataRequest) -> bool, open_terminal(OpenTerminalRequest) -> bool, + query_values(QueryValuesRequest) -> Vec, + match_flows(MatchFlowsRequest) -> Vec, + count_saved_queries(CountSavedQueriesRequest) -> Vec, } events { decrypt_list_changed(DecryptListChangedPayload), @@ -3187,6 +3376,7 @@ define_rpc! { message_write(MessagePayload), model_write(ModelPayload), proxy_state_changed(ProxyStatePayload), + saved_queries_changed(SavedQueriesChangedPayload), } } @@ -3256,6 +3446,7 @@ mod tests { db: ctx.db().unwrap(), blobs: ctx.blobs().unwrap(), events: ctx.events.clone(), + live_search: ctx.live_search.clone(), attribution: Attribution::new(0), recording: ctx.recording.clone(), finished: Default::default(), diff --git a/crates-proxy/yaak-proxy-lib/src/models.rs b/crates-proxy/yaak-proxy-lib/src/models.rs index a11671ef3..2d922fd07 100644 --- a/crates-proxy/yaak-proxy-lib/src/models.rs +++ b/crates-proxy/yaak-proxy-lib/src/models.rs @@ -107,6 +107,9 @@ pub struct Flow { /// client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes /// the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set. pub tunnelled: bool, + /// An agent read the flow through the control API: its detail, a body or its messages. + /// Never written by an upsert, which keeps what is stored. + pub agent_read: bool, } /// The streaming protocol a flow carries. @@ -194,6 +197,21 @@ pub fn find_header<'a>(headers: &'a [ProxyHeader], name: &str) -> Option<&'a str pub struct ModelPayload { pub model: Flow, pub change: ModelChangeEvent, + /// Whether the flow belongs in the list of the search the app shows, decided as the event + /// is sent. None while no search is shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub search: Option, +} + +/// A written flow, matched against the search the app shows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct SearchMatch { + /// The search's key, as the app gave it with the search. + pub key: String, + pub matches: bool, } impl UpsertModelInfo for Flow { @@ -357,6 +375,7 @@ impl UpsertModelInfo for Flow { grpc_status: added_column(r, "grpc_status")?, grpc_message: added_column(r, "grpc_message")?, tunnelled: added_column(r, "tunnelled")?.unwrap_or_default(), + agent_read: added_column(r, "agent_read")?.unwrap_or_default(), }) } } diff --git a/crates-proxy/yaak-proxy-lib/src/query.rs b/crates-proxy/yaak-proxy-lib/src/query.rs new file mode 100644 index 000000000..a91185092 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/query.rs @@ -0,0 +1,1242 @@ +//! Yaak Proxy's search: the query language of `yaak-query` over flows. +//! +//! A query is parsed by the crate, then prepared here: the shorthands the proxy keeps +//! (`@status:4xx`, `@host:*.github.com` for a site, `@path:/v1/*` for a folder and what is under +//! it, `@time:<5m`) become plain terms, and the hidden System flows are left out. The prepared +//! tree then runs two ways that must agree: compiled to a SQLite condition for listing and +//! counting ([`FlowQuery::sql`]), and in memory over one flow ([`FlowQuery::matches`]), for a +//! flow written while a search is shown. Both resolve each field term to the crate's +//! [`Condition`], and the values they test are made by the same functions here, which the +//! database calls as SQL functions ([`add_functions`]). + +use crate::flows::{folder_name, is_system, sidebar_path}; +use crate::models::{Flow, ModelPayload, ProxyHeader, SearchMatch, find_header}; +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use rusqlite::functions::FunctionFlags; +use rusqlite::types::{Value, ValueRef}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::{OnceLock, PoisonError, RwLock}; +use ts_rs::TS; +use yaak_database::ModelChangeEvent; +use yaak_query::{ + CompareOp, Condition, DocValue, DocValues, Document, FieldDef, FieldKind, FieldValue, FreeText, + Matcher, Node, NumberLiteral, Schema, Span, Unit, Utf16Offsets, contains_ignore_case, + format_number, read_number, wildcard_match, +}; + +/// The flags `@is:` takes, in the order completion offers them. +pub const FLAGS: &[&str] = &[ + "error", + "pending", + "system", + "tunnelled", + "saved", + "read", + "loop", + "ws", + "sse", + "grpc", +]; + +/// Flags `@is:` will take once flows have what they need. Valid, matching nothing. +const FLAGS_NOT_YET: &[&str] = &["marked"]; + +/// Fields declared so completion shows them, matching nothing until they are built. +const FIELDS_NOT_YET: &[&str] = &["body", "req.body", "res.body"]; + +/// The values `@proto:` takes. +pub const PROTOS: &[&str] = &["h1", "h2", "ws", "sse", "grpc"]; + +/// The proxy's fields. +pub fn schema() -> &'static Schema { + static SCHEMA: OnceLock = OnceLock::new(); + SCHEMA.get_or_init(|| { + let units = |units: &[(&str, f64)]| FieldKind::Number { + units: units + .iter() + .map(|(name, factor)| Unit { name: (*name).into(), factor: *factor }) + .collect(), + }; + // As the app shows sizes: a kilobyte is 1000 bytes. + let bytes = || units(&[("b", 1.0), ("kb", 1e3), ("mb", 1e6), ("gb", 1e9)]); + // Each with what it is and a term using it, as completion shows them + let field = |name: &str, kind: FieldKind, info: &str, example: &str| FieldDef { + name: name.into(), + kind, + map: false, + info: Some(info.into()), + example: (!example.is_empty()).then(|| example.into()), + }; + let map = |name: &str, info: &str, example: &str| FieldDef { + map: true, + ..field(name, FieldKind::Text, info, example) + }; + let text = || FieldKind::Text; + let number = || FieldKind::Number { units: vec![] }; + let ms = || units(&[("ms", 1.0), ("s", 1e3), ("m", 6e4)]); + Schema::new( + vec![ + field("host", text(), "Host, wildcards allowed", "@host:*.github.com"), + field("path", text(), "URL path, /* for below", "@path:/v1/*"), + field("method", text(), "GET, POST, …", "@method:post"), + field("status", number(), "Response status", "@status:4xx @status:>=400"), + field("type", text(), "Content type", "@type:json"), + field("app", text(), "App or program", "@app:Slack"), + field("is", text(), "error, pending, system, …", "@is:error -@is:system"), + map("header", "A header on either side", "@header.content-type:json"), + map("req.header", "A request header", "@req.header.accept:*"), + map("res.header", "A response header", "@res.header.x-cache:hit"), + field("size", bytes(), "Response body size", "@size:>100kb"), + field("req.size", bytes(), "Request body size", "@req.size:>1kb"), + field("res.size", bytes(), "Response body size", "@res.size:<1mb"), + field("duration", ms(), "Total time", "@duration:>500ms"), + field("time", units(TIME_UNITS), "When the flow started", "@time:<5m"), + field("url", text(), "The whole URL", "@url:*token=*"), + map("query", "A query parameter", "@query.page:2"), + field("scheme", text(), "http or https", "@scheme:https"), + field("port", number(), "Port", "@port:8080"), + field("proto", text(), "h1, h2, ws, sse, grpc", "@proto:h2"), + field("id", number(), "Flow number", "@id:>1200"), + field("cwd", text(), "Working folder", "@cwd:myapp"), + field("pid", number(), "Process id", "@pid:4121"), + field("body", text(), "Not yet", ""), + field("req.body", text(), "Not yet", ""), + field("res.body", text(), "Not yet", ""), + ], + FreeText::Substring, + ) + }) +} + +/// How long ago `@time` counts in, in milliseconds. +const TIME_UNITS: &[(&str, f64)] = &[ + ("ms", 1.0), + ("s", 1e3), + ("m", 6e4), + ("h", 3.6e6), + ("d", 8.64e7), + ("w", 6.048e8), +]; + +// -- Preparing -- + +/// A query prepared for running: its shorthands written out, System left out when hidden. +#[derive(Debug, Clone, PartialEq)] +pub struct FlowQuery { + root: Option, +} + +impl FlowQuery { + /// `text` as of now. With `hide_system`, macOS's own daemons are left out, unless the text + /// says `@is:system` itself. + pub fn new(text: &str, hide_system: bool) -> Self { + Self::at(text, hide_system, Utc::now()) + } + + /// [`Self::new`], with `now` for the times `@time` counts back from. + pub fn at(text: &str, hide_system: bool, now: DateTime) -> Self { + let parsed = yaak_query::parse(text); + let mentions_system = parsed.as_ref().is_some_and(mentions_system); + let mut root = parsed.map(|root| prepare(&root, now, &mut Vec::new())); + if hide_system && !mentions_system { + let span = Span::default(); + let not_system = Node::Not { node: Box::new(is_flag("system", span)), span }; + root = Some(match root { + None => not_system, + Some(root) => Node::And { children: vec![root, not_system], span }, + }); + } + Self { root } + } + + /// Covers every flow. + pub fn is_empty(&self) -> bool { + self.root.is_none() + } + + /// Whether `flow` is one the query covers, as [`Self::sql`] decides it in the database. + pub fn matches(&self, flow: &Flow) -> bool { + self.matcher().matches(&document(flow)) + } + + /// The query resolved for matching many flows in memory. + pub fn matcher(&self) -> Matcher { + Matcher::new(self.root.as_ref(), schema()) + } + + /// The SQL condition on `flows` and its parameters, for a database with `columns`. In + /// parentheses, so a caller may add to it with `AND`. + pub fn sql(&self, columns: &FlowColumns) -> (String, Vec) { + let mut sql = Sql { columns, params: Vec::new() }; + let condition = match &self.root { + None => "1".to_string(), + Some(root) => sql.node(root), + }; + (format!("({condition})"), sql.params) + } +} + +/// `@is:`, as a prepared term. +fn is_flag(flag: &str, span: Span) -> Node { + Node::Field { + path: vec!["is".into()], + value: FieldValue::Wildcard { pattern: flag.into() }, + span, + path_span: span, + value_span: span, + } +} + +/// The query names `@is:system`, either way, so hiding System leaves it be. +fn mentions_system(root: &Node) -> bool { + let mut found = false; + root.walk(&mut |node| { + if let Node::Field { path, value, .. } = node + && path.len() == 1 + && path[0].eq_ignore_ascii_case("is") + && let FieldValue::Text { text, .. } | FieldValue::Wildcard { pattern: text } = value + && text.eq_ignore_ascii_case("system") + { + found = true; + } + }); + found +} + +/// What the schema alone does not catch, found while preparing. +#[derive(Debug, Clone, PartialEq)] +struct Issue { + kind: QueryDiagnosticKind, + path: String, + span: Span, +} + +/// A term that matches nothing, in place of one that cannot run. +fn never(span: Span) -> Node { + Node::Phrase { text: String::new(), span } +} + +fn number(value: f64) -> NumberLiteral { + NumberLiteral { value, unit: None, raw: format_number(value) } +} + +/// `node` with the proxy's shorthands written out as plain terms, keeping their spans. +fn prepare(node: &Node, now: DateTime, issues: &mut Vec) -> Node { + match node { + Node::And { children, span } => Node::And { + children: children.iter().map(|c| prepare(c, now, issues)).collect(), + span: *span, + }, + Node::Or { children, span } => Node::Or { + children: children.iter().map(|c| prepare(c, now, issues)).collect(), + span: *span, + }, + Node::Not { node: inner, span } => { + Node::Not { node: Box::new(prepare(inner, now, issues)), span: *span } + } + Node::Term { .. } | Node::Phrase { .. } => node.clone(), + Node::Field { path, value, span, path_span, value_span } => { + let Some(field) = schema().resolve(path) else { + return node.clone(); + }; + let with = |value: FieldValue| Node::Field { + path: path.clone(), + value, + span: *span, + path_span: *path_span, + value_span: *value_span, + }; + let mut issue = |kind| { + let marked = if kind == QueryDiagnosticKind::NotYet { *span } else { *value_span }; + issues.push(Issue { kind, path: path.join("."), span: marked }); + never(*span) + }; + match (field.def.name.as_str(), value) { + // A class of statuses: `4xx` + ("status", FieldValue::Text { text, quoted: false }) => match status_class(text) { + Some(class) => with(FieldValue::Range { + low: number(class * 100.0), + high: number(class * 100.0 + 99.0), + }), + None => node.clone(), + }, + // A site: `*.github.com` is `github.com` and every host below it + ("host", FieldValue::Wildcard { pattern }) if pattern.starts_with("*.") => { + Node::Or { + children: vec![ + with(FieldValue::Wildcard { pattern: pattern[2..].to_string() }), + node.clone(), + ], + span: *span, + } + } + // A folder: `/v1/*` is `/v1` and everything under it + ("path", FieldValue::Wildcard { pattern }) if pattern.ends_with("/*") => Node::Or { + children: vec![ + with(FieldValue::Wildcard { pattern: pattern[..pattern.len() - 2].into() }), + node.clone(), + ], + span: *span, + }, + // Named whole, as the sidebar names them: a part needs wildcards + ("host" | "scheme" | "app" | "cwd", FieldValue::Text { text, .. }) + if !text.is_empty() => + { + with(FieldValue::Wildcard { pattern: text.clone() }) + } + ("is", FieldValue::Text { text, .. }) if !text.is_empty() => { + let flag = text.to_lowercase(); + if FLAGS.contains(&flag.as_str()) { + with(FieldValue::Wildcard { pattern: flag }) + } else if FLAGS_NOT_YET.contains(&flag.as_str()) { + issue(QueryDiagnosticKind::NotYet) + } else { + issue(QueryDiagnosticKind::InvalidValue) + } + } + // Not written yet + ("time", FieldValue::Text { text, .. }) if text.is_empty() => node.clone(), + ("time", value) => match time_node(value, now, &with) { + Some(node) => node, + None => issue(QueryDiagnosticKind::InvalidValue), + }, + (name, _) if FIELDS_NOT_YET.contains(&name) => issue(QueryDiagnosticKind::NotYet), + _ => node.clone(), + } + } + } +} + +/// The class `4xx` names, 4, or None when `text` is not one. +fn status_class(text: &str) -> Option { + let class = text.strip_suffix("xx").or_else(|| text.strip_suffix("XX"))?; + match class.as_bytes() { + [d @ b'1'..=b'9'] => Some(f64::from(d - b'0')), + _ => None, + } +} + +/// `@time`'s value as plain comparisons of when flows started, in Unix milliseconds. A +/// comparison or range of durations is how long ago (`<5m`: in the last five minutes); a date, +/// `2026-11-07`, a time today, `14:30`, or both, `2026-11-07T14:30`, all local, or an ISO time +/// with its offset, is the span it names, compared whole. None when the value is none of these. +fn time_node( + value: &FieldValue, + now: DateTime, + with: &impl Fn(FieldValue) -> Node, +) -> Option { + let now_ms = now.timestamp_millis() as f64; + let compare = |op, ms: f64| with(FieldValue::Compare { op, number: number(ms) }); + match value { + FieldValue::Exists => Some(with(FieldValue::Exists)), + FieldValue::Compare { op, number: age } => { + // Less long ago is later + let op = match op { + CompareOp::Gt => CompareOp::Lt, + CompareOp::Gte => CompareOp::Lte, + CompareOp::Lt => CompareOp::Gt, + CompareOp::Lte => CompareOp::Gte, + }; + Some(compare(op, now_ms - age_ms(age)?)) + } + FieldValue::Range { low, high } => { + let (a, b) = (age_ms(low)?, age_ms(high)?); + Some(with(FieldValue::Range { + low: number(now_ms - a.max(b)), + high: number(now_ms - a.min(b)), + })) + } + FieldValue::Text { text, .. } => { + let (op, point) = split_op(text); + let (start, end) = time_span(point, now)?; + Some(match op { + None => { + let span = Span::default(); + Node::And { + children: vec![compare(CompareOp::Gte, start), compare(CompareOp::Lt, end)], + span, + } + } + Some(CompareOp::Gt) => compare(CompareOp::Gte, end), + Some(CompareOp::Gte) => compare(CompareOp::Gte, start), + Some(CompareOp::Lt) => compare(CompareOp::Lt, start), + Some(CompareOp::Lte) => compare(CompareOp::Lt, end), + }) + } + FieldValue::Wildcard { .. } => None, + } +} + +/// A duration in milliseconds, which must have a unit. +fn age_ms(number: &NumberLiteral) -> Option { + let unit = number.unit.as_deref()?; + let (_, factor) = TIME_UNITS.iter().find(|(name, _)| name.eq_ignore_ascii_case(unit))?; + Some(number.value * factor) +} + +fn split_op(text: &str) -> (Option, &str) { + for (prefix, op) in [ + (">=", CompareOp::Gte), + ("<=", CompareOp::Lte), + (">", CompareOp::Gt), + ("<", CompareOp::Lt), + ] { + if let Some(rest) = text.strip_prefix(prefix) { + return (Some(op), rest); + } + } + (None, text) +} + +/// The span of time `text` names, as Unix milliseconds from its start to just past its end. +fn time_span(text: &str, now: DateTime) -> Option<(f64, f64)> { + let local = |dt: NaiveDateTime| Local.from_local_datetime(&dt).earliest(); + let ms = |dt: DateTime| dt.timestamp_millis() as f64; + if let Ok(dt) = DateTime::parse_from_rfc3339(text) { + let start = dt.timestamp_millis() as f64; + return Some((start, start + 1e3)); + } + if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m-%d") { + let start = local(date.and_time(NaiveTime::MIN))?; + let end = local(date.succ_opt()?.and_time(NaiveTime::MIN))?; + return Some((ms(start), ms(end))); + } + let today = now.with_timezone(&Local).date_naive(); + for (format, length) in [ + ("%Y-%m-%dT%H:%M:%S", 1e3), + ("%Y-%m-%dT%H:%M", 6e4), + ("%H:%M:%S", 1e3), + ("%H:%M", 6e4), + ] { + let parsed = NaiveDateTime::parse_from_str(text, format) + .ok() + .or_else(|| NaiveTime::parse_from_str(text, format).ok().map(|t| today.and_time(t))); + if let Some(dt) = parsed { + let start = ms(local(dt)?); + return Some((start, start + length)); + } + } + None +} + +// -- Live flows -- + +/// The search the app shows, while it shows one. Each flow written meanwhile is matched +/// against it as its event is sent, so the event says whether it belongs in the search's list +/// and the app needs no matching of its own. +#[derive(Debug, Default)] +pub struct LiveSearch(RwLock>); + +#[derive(Debug)] +struct Watched { + key: String, + text: String, + hide_system: bool, +} + +impl LiveSearch { + /// Matches flows against `text` from now on, their events naming `key`; None stops. + pub fn watch(&self, watched: Option<(String, String, bool)>) { + let watched = watched.map(|(key, text, hide_system)| Watched { key, text, hide_system }); + *self.0.write().unwrap_or_else(PoisonError::into_inner) = watched; + } + + /// `flow` matched against the search shown, if any. + pub fn check(&self, flow: &Flow) -> Option { + let watched = self.0.read().unwrap_or_else(PoisonError::into_inner); + let Watched { key, text, hide_system } = watched.as_ref()?; + // Prepared again for each flow, so `@time:<5m` counts back from now. + let matches = FlowQuery::new(text, *hide_system).matches(flow); + Some(SearchMatch { key: key.clone(), matches }) + } + + /// The event for `flow`, written: created, or updated. + pub fn payload(&self, flow: Flow, created: bool) -> ModelPayload { + let search = self.check(&flow); + ModelPayload { model: flow, change: ModelChangeEvent::Upsert { created }, search } + } +} + +// -- Seen values, for completion -- + +/// The newest flows completion looks through for values. +const SEEN_FLOWS: i64 = 2000; + +/// Values offered per field at most. +const SEEN_VALUES: i64 = 200; + +/// A header's values are offered only when it has had at most this many: more, and they are +/// near-unique (dates, ids), which is noise. +const HEADER_VALUES: i64 = 20; + +/// The values seen for a field path, for completing it, most recent first: hosts, methods, +/// statuses, content types, protocols, schemes, apps, working folders, `@is` flags, and header +/// names, or a header's values when it has had few. Nothing for fields whose values are +/// near-unique (URLs, paths, ids, sizes, times, query parameters). +pub fn seen_values(db: &crate::db::ProxyQueryManager, path: &str) -> Result, String> { + let path = path.to_lowercase(); + let column = match path.as_str() { + "proto" => return Ok(PROTOS.iter().map(|p| p.to_string()).collect()), + "is" => return Ok(FLAGS.iter().map(|f| f.to_string()).collect()), + "host" => "url_host(url)", + "method" => "method", + "status" => "CAST(status AS TEXT)", + "type" => "coalesce(content_type, header_value(request_headers, 'content-type'))", + "scheme" => "url_scheme(url)", + "app" => "client_name", + "cwd" => "folder_name(client_cwd)", + "header" | "req.header" | "res.header" => { + return seen_headers(db, &path, None); + } + _ => { + for map in ["req.header.", "res.header.", "header."] { + if let Some(name) = path.strip_prefix(map) { + return seen_headers(db, &map[..map.len() - 1], Some(name)); + } + } + return Ok(Vec::new()); + } + }; + let sql = format!( + "SELECT v FROM (SELECT {column} AS v, rid FROM ({})) WHERE v IS NOT NULL AND v != '' \ + GROUP BY v ORDER BY max(rid) DESC LIMIT {SEEN_VALUES}", + newest_flows() + ); + let mut values = select_strings(db, &sql, None)?; + if path == "type" { + // The media type alone, as a search names it + let mut seen = HashSet::new(); + values = values + .into_iter() + .filter_map(|t| t.split(';').next().map(|m| m.trim().to_lowercase())) + .filter(|m| !m.is_empty() && seen.insert(m.clone())) + .collect(); + } + Ok(values) +} + +/// The newest flows not cleared, with their rowid as `rid`. +fn newest_flows() -> String { + format!( + "SELECT rowid AS rid, * FROM flows WHERE rowid > (SELECT coalesce(max(max_rowid), 0) \ + FROM flow_clear_mark) ORDER BY rowid DESC LIMIT {SEEN_FLOWS}" + ) +} + +/// Header names under a map, or one header's values when it has had few. +fn seen_headers( + db: &crate::db::ProxyQueryManager, + map: &str, + name: Option<&str>, +) -> Result, String> { + let sides = match map { + "req.header" => vec!["request_headers"], + "res.header" => vec!["response_headers"], + _ => vec!["request_headers", "response_headers"], + }; + let pairs = sides + .iter() + .map(|side| { + format!( + "SELECT q_lower(json_extract(kv.value, '$.name')) AS name, \ + json_extract(kv.value, '$.value') AS value, f.rid AS rid \ + FROM ({}) AS f, json_each(f.{side}) AS kv", + newest_flows() + ) + }) + .collect::>() + .join(" UNION ALL "); + match name { + None => { + let sql = format!( + "SELECT name FROM ({pairs}) WHERE name IS NOT NULL AND name != '' \ + GROUP BY name ORDER BY max(rid) DESC LIMIT {SEEN_VALUES}" + ); + select_strings(db, &sql, None) + } + Some(name) => { + let sql = format!( + "SELECT value FROM ({pairs}) WHERE name = ? AND value IS NOT NULL AND value != '' \ + GROUP BY value ORDER BY max(rid) DESC LIMIT {}", + HEADER_VALUES + 1 + ); + let values = select_strings(db, &sql, Some(name.to_lowercase()))?; + Ok(if values.len() as i64 > HEADER_VALUES { Vec::new() } else { values }) + } + } +} + +fn select_strings( + db: &crate::db::ProxyQueryManager, + sql: &str, + param: Option, +) -> Result, String> { + db.with_conn(|c| { + let conn = c.conn().resolve(); + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + let params: Vec = param.into_iter().map(Value::from).collect(); + let rows = stmt + .query_map(rusqlite::params_from_iter(params), |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + }) +} + +// -- Diagnostics -- + +/// What is wrong with a term, for the search box to mark. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum QueryDiagnosticKind { + /// No field has this path. + UnknownField, + /// The value cannot apply to the field: a unit it does not take, a flag it does not have. + InvalidValue, + /// Searchable later; matches nothing for now. + NotYet, +} + +/// A term the query cannot run, which then matches nothing. `start` and `end` count UTF-16 +/// code units, as JavaScript does. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct QueryDiagnostic { + pub kind: QueryDiagnosticKind, + pub path: String, + pub start: u32, + pub end: u32, +} + +/// The terms of `text` that cannot run, in order. +pub fn diagnostics(text: &str) -> Vec { + let Some(root) = yaak_query::parse(text) else { + return Vec::new(); + }; + let mut issues = Vec::new(); + let prepared = prepare(&root, Utc::now(), &mut issues); + for diagnostic in schema().diagnostics(&prepared) { + issues.push(match diagnostic { + yaak_query::Diagnostic::UnknownField { path, span } => { + Issue { kind: QueryDiagnosticKind::UnknownField, path, span } + } + yaak_query::Diagnostic::InvalidValue { path, span } => { + Issue { kind: QueryDiagnosticKind::InvalidValue, path, span } + } + }); + } + issues.sort_by_key(|i| (i.span.start, i.span.end)); + let offsets = Utf16Offsets::new(text); + issues + .into_iter() + .map(|Issue { kind, path, span }| { + let span = offsets.span(span); + QueryDiagnostic { kind, path, start: span.start, end: span.end } + }) + .collect() +} + +// -- The values a flow is matched by -- + +/// The parts of a URL the fields read. A URL without a scheme, as an undecrypted connection's +/// may be, is all host. +struct UrlParts<'a> { + scheme: Option<&'a str>, + authority: &'a str, + path: Option<&'a str>, + query: Option<&'a str>, +} + +fn url_parts(url: &str) -> UrlParts<'_> { + let Some((scheme, rest)) = url.split_once("://") else { + return UrlParts { scheme: None, authority: url, path: None, query: None }; + }; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, rest) = rest.split_at(end); + let rest = rest.split('#').next().unwrap_or_default(); + let (path, query) = match rest.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (rest, None), + }; + UrlParts { scheme: Some(scheme), authority, path: Some(path), query } +} + +/// The authority less any user info. +fn host_port(authority: &str) -> &str { + authority.rsplit_once('@').map_or(authority, |(_, host)| host) +} + +/// The host, lowercased and without its port. +pub fn url_hostname(url: &str) -> String { + let host = host_port(url_parts(url).authority); + let host = if host.starts_with('[') { + host.find(']').map_or(host, |end| &host[..=end]) + } else { + host.rsplit_once(':').map_or(host, |(h, _)| h) + }; + host.to_lowercase() +} + +/// The host as the sidebar names it: with its port when that is not the scheme's. +pub fn url_host(url: &str) -> String { + sidebar_path(url).split('/').next().unwrap_or_default().to_string() +} + +/// The port the URL names, or its scheme's. +pub fn url_port(url: &str) -> Option { + let parts = url_parts(url); + let host = host_port(parts.authority); + let after_host = if host.starts_with('[') { + host.find(']').map(|end| &host[end + 1..]).unwrap_or_default() + } else { + host + }; + if let Some((_, port)) = after_host.rsplit_once(':') + && let Ok(port) = port.parse() + { + return Some(port); + } + match parts.scheme?.to_ascii_lowercase().as_str() { + "http" | "ws" => Some(80), + "https" | "wss" => Some(443), + _ => None, + } +} + +pub fn url_scheme(url: &str) -> Option { + url_parts(url).scheme.map(str::to_lowercase) +} + +/// The path, without the query or fragment. +pub fn url_path(url: &str) -> Option<&str> { + url_parts(url).path +} + +/// The path as the sidebar names it, its empty segments left out (`/v1//users` is +/// `/v1/users`), when that differs from the path, so a sidebar row's term finds it. +pub fn url_sidebar_path(url: &str) -> Option { + let path = url_path(url)?; + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let normalized = format!("/{}", segments.join("/")); + (normalized != path && !path.is_empty()).then_some(normalized) +} + +/// The query's parameters, decoded, their names lowercased. +pub fn url_query(url: &str) -> Vec<(String, String)> { + let Some(query) = url_parts(url).query else { + return Vec::new(); + }; + url::form_urlencoded::parse(query.as_bytes()) + .filter(|(name, _)| !name.is_empty()) + .map(|(name, value)| (name.to_lowercase(), value.into_owned())) + .collect() +} + +/// What bare words search: the URL, the method, the status and the content type. +pub fn flow_text( + url: &str, + method: &str, + status: Option, + content_type: Option<&str>, +) -> String { + let status = status.map(|s| s.to_string()).unwrap_or_default(); + format!("{url}\n{method}\n{status}\n{}", content_type.unwrap_or_default()) +} + +/// `h1` or `h2`, from the version the client spoke. +pub fn http_proto(version: &str) -> Option<&'static str> { + match version { + "HTTP/2" | "HTTP/2.0" => Some("h2"), + "HTTP/3" | "HTTP/3.0" => Some("h3"), + v if v.starts_with("HTTP/1") || v.starts_with("HTTP/0") => Some("h1"), + _ => None, + } +} + +/// `ws`, `sse` or `grpc`, from a stream's kind as stored. +pub fn stream_proto(kind: &str) -> Option<&'static str> { + match kind { + "websocket" => Some("ws"), + "sse" => Some("sse"), + "grpc" | "grpc_web" => Some("grpc"), + _ => None, + } +} + +/// The flags `@is:` finds on `flow`. +fn flags(flow: &Flow) -> Vec<&'static str> { + let stream = flow.stream_kind.map(|k| k.as_str()); + let set = |flag: &str| match flag { + "error" => flow.error.is_some() || flow.grpc_status.unwrap_or(0) != 0, + "pending" => { + flow.status.is_none() + && flow.error.is_none() + && !flow.cancelled + && flow.elapsed_ms.is_none() + } + "system" => is_system(flow.client_bundle_id.as_deref(), flow.client_path.as_deref()), + "tunnelled" => flow.tunnelled, + "saved" => flow.saved_request_id.is_some(), + "read" => flow.agent_read, + "loop" => flow.status == Some(508), + "ws" => stream == Some("websocket"), + "sse" => stream == Some("sse"), + "grpc" => matches!(stream, Some("grpc" | "grpc_web")), + _ => false, + }; + FLAGS.iter().copied().filter(|f| set(f)).collect() +} + +/// The response's content type, or the request's when it has none. +fn content_type(flow: &Flow) -> Option<&str> { + flow.content_type.as_deref().or_else(|| find_header(&flow.request_headers, "content-type")) +} + +/// `flow` as the evaluator sees it, with the values [`FlowQuery::sql`] reads in the database. +pub fn document(flow: &Flow) -> Document { + let mut fields: HashMap> = HashMap::new(); + let mut add = |path: &str, value: DocValue| { + fields.entry(path.to_lowercase()).or_default().push(value); + }; + let text = |s: &str| DocValue::Text(s.to_string()); + let num = |n: f64| DocValue::Number(n); + + add("host", DocValue::Text(url_hostname(&flow.url))); + add("host", DocValue::Text(url_host(&flow.url))); + if let Some(port) = url_port(&flow.url) { + add("port", num(port as f64)); + } + if let Some(scheme) = url_scheme(&flow.url) { + add("scheme", DocValue::Text(scheme)); + } + if let Some(path) = url_path(&flow.url) { + add("path", text(path)); + } + if let Some(path) = url_sidebar_path(&flow.url) { + add("path", DocValue::Text(path)); + } + add("url", text(&flow.url)); + for (name, value) in url_query(&flow.url) { + add(&format!("query.{name}"), DocValue::Text(value)); + } + add("method", text(&flow.method)); + if let Some(t) = content_type(flow) { + add("type", text(t)); + } + if let Some(p) = http_proto(&flow.client_http_version) { + add("proto", text(p)); + } + if let Some(p) = flow.stream_kind.and_then(|k| stream_proto(k.as_str())) { + add("proto", text(p)); + } + add("id", num(flow.number as f64)); + if let Some(status) = flow.status { + add("status", num(f64::from(status))); + } + add("size", num(flow.response_size as f64)); + add("res.size", num(flow.response_size as f64)); + add("req.size", num(flow.request_size as f64)); + if let Some(ms) = flow.elapsed_ms { + add("duration", num(f64::from(ms))); + } + add("time", num(started_ms(&flow.started_at))); + if let Some(app) = flow.client_bundle_id.as_deref().or(flow.client_path.as_deref()) { + add("app", text(app)); + } + if let Some(name) = &flow.client_name { + add("app", text(name)); + } + if flow.client_bundle_id.is_none() + && let Some(folder) = folder_name(flow.client_cwd.as_deref()) + { + add("app", text(folder)); + } + if let Some(cwd) = &flow.client_cwd { + add("cwd", text(cwd)); + if let Some(folder) = folder_name(Some(cwd)) { + add("cwd", text(folder)); + } + } + if let Some(pid) = flow.client_pid { + add("pid", num(f64::from(pid))); + } + for (side, headers) in [ + ("req", &flow.request_headers), + ("res", &flow.response_headers), + ] { + for ProxyHeader { name, value } in headers.iter().filter(|h| !h.name.is_empty()) { + add(&format!("header.{name}"), text(value)); + add(&format!("{side}.header.{name}"), text(value)); + } + } + for flag in flags(flow) { + add("is", text(flag)); + } + + Document { + text: flow_text( + &flow.url, + &flow.method, + flow.status.map(i64::from), + flow.content_type.as_deref(), + ), + fields: fields.into_iter().map(|(path, values)| (path, DocValues::Many(values))).collect(), + } +} + +/// When a flow started, in Unix milliseconds, fractions kept, as `@time` compares it. +fn started_ms(started_at: &NaiveDateTime) -> f64 { + let utc = started_at.and_utc(); + utc.timestamp() as f64 * 1e3 + f64::from(utc.timestamp_subsec_nanos()) / 1e6 +} + +/// Unix milliseconds as `started_at` is stored, so the database compares them as text in its +/// index's order: equal times are equal text, and later ones sort after. +fn stored_time(ms: f64) -> String { + let nanos = (ms * 1e6).round() as i64; + let dt = DateTime::::from_timestamp_nanos(nanos).naive_utc(); + dt.format("%Y-%m-%d %H:%M:%S%.f").to_string() +} + +// -- Compiling to SQL -- + +/// The columns of a database's flows table: one opened read-only before its migrations ran may +/// lack later ones, which then read as the model reads them, empty. +#[derive(Debug, Clone)] +pub struct FlowColumns(HashSet); + +impl FlowColumns { + pub fn of(conn: &rusqlite::Connection) -> Self { + let names = conn + .prepare("SELECT name FROM pragma_table_info('flows')") + .and_then(|mut stmt| { + stmt.query_map([], |r| r.get::<_, String>(0))?.collect::>() + }) + .unwrap_or_default(); + Self(names) + } + + /// `name`, or what the model reads in its place when the table lacks it. + fn col(&self, name: &str) -> String { + if self.0.contains(name) { + return name.to_string(); + } + match name { + "cancelled" | "tunnelled" | "agent_read" => "0".into(), + _ => "NULL".into(), + } + } +} + +struct Sql<'a> { + columns: &'a FlowColumns, + params: Vec, +} + +impl Sql<'_> { + fn param(&mut self, value: impl Into) -> &'static str { + self.params.push(value.into()); + "?" + } + + fn node(&mut self, node: &Node) -> String { + match node { + Node::And { children, .. } => self.join(children, " AND ", "1"), + Node::Or { children, .. } => self.join(children, " OR ", "0"), + Node::Not { node, .. } => format!("NOT ({})", self.node(node)), + Node::Term { text, .. } => self.free_text(text.trim().to_lowercase()), + Node::Phrase { text, .. } => self.free_text(text.to_lowercase()), + Node::Field { path, value, .. } => { + let Some(field) = schema().resolve(path) else { + return "0".into(); + }; + let condition = Condition::new(&field.def.kind, value); + if condition == Condition::Never { + return "0".into(); + } + self.field(&field.def.name, field.key.as_deref(), &condition) + } + } + } + + fn join(&mut self, children: &[Node], op: &str, empty: &str) -> String { + if children.is_empty() { + return empty.into(); + } + let parts: Vec = children.iter().map(|c| format!("({})", self.node(c))).collect(); + parts.join(op) + } + + fn free_text(&mut self, needle: String) -> String { + if needle.is_empty() { + return "0".into(); + } + let p = self.param(needle); + format!("coalesce(q_contains(flow_text(url, method, status, content_type), {p}), 0)") + } + + fn field(&mut self, name: &str, key: Option<&str>, condition: &Condition) -> String { + let c = |n: &str| self.columns.col(n); + let (values, numeric): (Vec, bool) = match name { + "host" => (vec!["url_hostname(url)".into(), "url_host(url)".into()], false), + "port" => (vec!["url_port(url)".into()], true), + "scheme" => (vec!["url_scheme(url)".into()], false), + "path" => (vec!["url_path(url)".into(), "url_sidebar_path(url)".into()], false), + "url" => (vec!["url".into()], false), + "method" => (vec!["method".into()], false), + "type" => ( + vec![ + "coalesce(content_type, header_value(request_headers, 'content-type'))".into(), + ], + false, + ), + "proto" => ( + vec![ + "http_proto(client_http_version)".into(), + format!("stream_proto({})", c("stream_kind")), + ], + false, + ), + "id" => (vec!["flows.rowid".into()], true), + "status" => (vec!["status".into()], true), + "size" | "res.size" => (vec!["response_size".into()], true), + "req.size" => (vec!["request_size".into()], true), + "duration" => (vec!["elapsed_ms".into()], true), + "pid" => (vec![c("client_pid")], true), + "app" => { + let (bundle, path, name) = + (c("client_bundle_id"), c("client_path"), c("client_name")); + let folder = + format!("CASE WHEN {bundle} IS NULL THEN folder_name({}) END", c("client_cwd")); + (vec![format!("coalesce({bundle}, {path})"), name, folder], false) + } + "cwd" => { + let cwd = c("client_cwd"); + (vec![cwd.clone(), format!("folder_name({cwd})")], false) + } + "time" => return self.time(condition), + "is" => return self.flags(condition), + "header" => { + let both = [ + self.pairs("request_headers", key, condition), + self.pairs("response_headers", key, condition), + ]; + return format!("({} OR {})", both[0], both[1]); + } + "req.header" => return self.pairs("request_headers", key, condition), + "res.header" => return self.pairs("response_headers", key, condition), + "query" => return self.pairs("url_query_json(url)", key, condition), + _ => return "0".into(), + }; + self.any_value(&values, numeric, condition) + } + + /// Any of the values passes. + fn any_value(&mut self, values: &[String], numeric: bool, condition: &Condition) -> String { + let parts: Vec = values.iter().map(|v| self.test(v, numeric, condition)).collect(); + if parts.len() == 1 { + parts.into_iter().next().unwrap_or_default() + } else { + format!("({})", parts.join(" OR ")) + } + } + + /// `condition` on one value, never NULL. `numeric` for a value stored as a number, compared + /// without reading it as one first. + fn test(&mut self, value: &str, numeric: bool, condition: &Condition) -> String { + let as_number = if numeric { value.to_string() } else { format!("q_number({value})") }; + let test = match condition { + Condition::Contains(needle) => { + format!("q_contains({value}, {})", self.param(needle.clone())) + } + Condition::Wildcard(pattern) => { + format!("q_wildcard({value}, {})", self.param(pattern.clone())) + } + Condition::Present => format!("q_present({value})"), + Condition::IsNumber => format!("{as_number} IS NOT NULL"), + Condition::Equals(n) => format!("{as_number} = {}", self.param(*n)), + Condition::Compare(op, n) => format!("{as_number} {} {}", op.as_str(), self.param(*n)), + Condition::Range(low, high) => { + format!("{as_number} BETWEEN {} AND {}", self.param(*low), self.param(*high)) + } + // The proxy has no flag fields; `@is` is text. + Condition::Flag(_) | Condition::Never => return "0".into(), + }; + format!("coalesce({test}, 0)") + } + + /// `@time`, prepared into comparisons of Unix milliseconds, compared with `started_at` as + /// stored, through its index. + fn time(&mut self, condition: &Condition) -> String { + match condition { + Condition::Present | Condition::IsNumber => "1".into(), + Condition::Equals(ms) => format!("started_at = {}", self.param(stored_time(*ms))), + Condition::Compare(op, ms) => { + format!("started_at {} {}", op.as_str(), self.param(stored_time(*ms))) + } + Condition::Range(low, high) => format!( + "started_at BETWEEN {} AND {}", + self.param(stored_time(*low)), + self.param(stored_time(*high)) + ), + // Preparing leaves `@time` nothing else. + _ => "0".into(), + } + } + + /// A flag's condition on the flow's columns. + fn flag(&self, flag: &str) -> String { + let c = |n: &str| self.columns.col(n); + match flag { + "error" => format!("(error IS NOT NULL OR coalesce({}, 0) != 0)", c("grpc_status")), + "pending" => format!( + "(status IS NULL AND error IS NULL AND NOT {} AND elapsed_ms IS NULL)", + c("cancelled") + ), + "system" => format!("is_system({}, {})", c("client_bundle_id"), c("client_path")), + "tunnelled" => c("tunnelled"), + "saved" => format!("{} IS NOT NULL", c("saved_request_id")), + "read" => c("agent_read"), + "loop" => "status = 508".into(), + "ws" => format!("{} = 'websocket'", c("stream_kind")), + "sse" => format!("{} = 'sse'", c("stream_kind")), + "grpc" => format!("{} IN ('grpc', 'grpc_web')", c("stream_kind")), + _ => "0".into(), + } + } + + /// `@is`: each flag set is a value, its name. A flag named whole is its column condition. + fn flags(&mut self, condition: &Condition) -> String { + if let Condition::Wildcard(pattern) = condition + && !pattern.contains('*') + { + return match FLAGS.contains(&pattern.as_str()) { + true => format!("coalesce({}, 0)", self.flag(pattern)), + false => "0".into(), + }; + } + let values: Vec = FLAGS + .iter() + .map(|f| format!("CASE WHEN coalesce({}, 0) THEN '{f}' END", self.flag(f))) + .collect(); + self.any_value(&values, false, condition) + } + + /// A map's values in a JSON array of `{ name, value }`: under `key` when given, any + /// otherwise. + fn pairs(&mut self, json: &str, key: Option<&str>, condition: &Condition) -> String { + let name = match key { + Some(key) => { + format!( + "q_lower(json_extract(kv.value, '$.name')) = {} AND ", + self.param(key.to_lowercase()) + ) + } + None => String::new(), + }; + let test = self.test("json_extract(kv.value, '$.value')", false, condition); + format!("EXISTS (SELECT 1 FROM json_each({json}) AS kv WHERE {name}{test})") + } +} + +// -- SQL functions -- + +/// A value as the evaluator reads text from it: text as it is, a number as `200`. +fn value_text(value: ValueRef<'_>) -> Option> { + match value { + ValueRef::Text(t) => Some(String::from_utf8_lossy(t)), + ValueRef::Integer(i) => Some(format_number(i as f64).into()), + ValueRef::Real(f) => Some(format_number(f).into()), + ValueRef::Null | ValueRef::Blob(_) => None, + } +} + +/// Adds the functions [`FlowQuery::sql`] compiles to: the value tests of the crate's +/// conditions, and the values fields read from a flow's columns. +pub(crate) fn add_functions(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC; + let text_arg = |ctx: &rusqlite::functions::Context, i| ctx.get::>(i); + + conn.create_scalar_function("q_contains", 2, flags, |ctx| { + let needle = ctx.get::(1)?; + Ok(match ctx.get_raw(0) { + ValueRef::Text(t) => contains_ignore_case(&String::from_utf8_lossy(t), &needle), + v => value_text(v).is_some_and(|t| t.contains(needle.as_str())), + }) + })?; + conn.create_scalar_function("q_wildcard", 2, flags, |ctx| { + let pattern = ctx.get::(1)?; + Ok(match ctx.get_raw(0) { + ValueRef::Text(t) => { + wildcard_match(&pattern, &String::from_utf8_lossy(t).to_lowercase()) + } + v => value_text(v).is_some_and(|t| wildcard_match(&pattern, &t)), + }) + })?; + conn.create_scalar_function("q_present", 1, flags, |ctx| { + Ok(match ctx.get_raw(0) { + ValueRef::Text(t) => !t.is_empty(), + ValueRef::Integer(_) | ValueRef::Real(_) => true, + ValueRef::Null | ValueRef::Blob(_) => false, + }) + })?; + conn.create_scalar_function("q_number", 1, flags, |ctx| { + Ok(match ctx.get_raw(0) { + ValueRef::Integer(i) => Some(i as f64), + ValueRef::Real(f) => Some(f), + ValueRef::Text(t) => read_number(&String::from_utf8_lossy(t)), + ValueRef::Null | ValueRef::Blob(_) => None, + }) + })?; + conn.create_scalar_function("q_lower", 1, flags, move |ctx| { + Ok(text_arg(ctx, 0)?.map(|t| t.to_lowercase())) + })?; + conn.create_scalar_function("flow_text", 4, flags, move |ctx| { + let url = text_arg(ctx, 0)?.unwrap_or_default(); + let method = text_arg(ctx, 1)?.unwrap_or_default(); + let content_type = text_arg(ctx, 3)?; + Ok(flow_text(&url, &method, ctx.get::>(2)?, content_type.as_deref())) + })?; + conn.create_scalar_function("url_hostname", 1, flags, move |ctx| { + Ok(url_hostname(&text_arg(ctx, 0)?.unwrap_or_default())) + })?; + conn.create_scalar_function("url_host", 1, flags, move |ctx| { + Ok(url_host(&text_arg(ctx, 0)?.unwrap_or_default())) + })?; + conn.create_scalar_function("url_port", 1, flags, move |ctx| { + Ok(url_port(&text_arg(ctx, 0)?.unwrap_or_default())) + })?; + conn.create_scalar_function("url_scheme", 1, flags, move |ctx| { + Ok(url_scheme(&text_arg(ctx, 0)?.unwrap_or_default())) + })?; + conn.create_scalar_function("url_path", 1, flags, move |ctx| { + Ok(url_path(&text_arg(ctx, 0)?.unwrap_or_default()).map(str::to_string)) + })?; + conn.create_scalar_function("url_sidebar_path", 1, flags, move |ctx| { + Ok(url_sidebar_path(&text_arg(ctx, 0)?.unwrap_or_default())) + })?; + conn.create_scalar_function("url_query_json", 1, flags, move |ctx| { + let pairs: Vec = url_query(&text_arg(ctx, 0)?.unwrap_or_default()) + .into_iter() + .map(|(name, value)| ProxyHeader { name, value }) + .collect(); + serde_json::to_string(&pairs).map_err(|e| rusqlite::Error::UserFunctionError(e.into())) + })?; + conn.create_scalar_function("header_value", 2, flags, move |ctx| { + let headers: Vec = + serde_json::from_str(&text_arg(ctx, 0)?.unwrap_or_default()).unwrap_or_default(); + let name = ctx.get::(1)?; + Ok(find_header(&headers, &name).map(str::to_string)) + })?; + conn.create_scalar_function("http_proto", 1, flags, move |ctx| { + Ok(text_arg(ctx, 0)?.and_then(|v| http_proto(&v))) + })?; + conn.create_scalar_function("stream_proto", 1, flags, move |ctx| { + Ok(text_arg(ctx, 0)?.and_then(|v| stream_proto(&v))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates-proxy/yaak-proxy-lib/src/query/tests.rs b/crates-proxy/yaak-proxy-lib/src/query/tests.rs new file mode 100644 index 000000000..499ecf52a --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/query/tests.rs @@ -0,0 +1,1001 @@ +//! The compiler's tests. Each term kind against flows made for it, then the drift guard: for a +//! sample of varied flows and a battery of queries, the database's answer and the evaluator's +//! must be the same flows. + +use super::*; +use crate::db::ProxyQueryManager; +use crate::models::StreamKind; +use chrono::Duration; +use yaak_database::UpdateSource; + +/// When the tests run: every flow's start and every `@time` counts from here. +fn now() -> DateTime { + DateTime::parse_from_rfc3339("2026-11-07T12:00:00Z").unwrap().with_timezone(&Utc) +} + +fn header(name: &str, value: &str) -> ProxyHeader { + ProxyHeader { name: name.into(), value: value.into() } +} + +/// A database holding `flows`, numbered in order, with what they read back as. +fn db_of(flows: Vec) -> (tempfile::TempDir, ProxyQueryManager, Vec) { + let dir = tempfile::tempdir().unwrap(); + let db = ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap(); + let mut stored = Vec::new(); + for flow in flows { + let read = flow.agent_read; + let (mut saved, _) = db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + if read { + db.with_tx(|tx| { + tx.conn() + .resolve() + .execute("UPDATE flows SET agent_read = TRUE WHERE id = ?", [&saved.id]) + .map_err(yaak_database::Error::from) + }) + .unwrap(); + saved.agent_read = true; + } + stored.push(saved); + } + (dir, db, stored) +} + +/// The ids the database finds for `query`, sorted. +fn sql_ids(db: &ProxyQueryManager, query: &FlowQuery) -> Vec { + db.with_conn(|c| { + let conn = c.conn().resolve(); + let (condition, params) = query.sql(&FlowColumns::of(conn)); + let sql = format!("SELECT id FROM flows WHERE {condition} ORDER BY id"); + let mut stmt = conn.prepare(&sql).unwrap_or_else(|e| panic!("{e}: {sql}")); + stmt.query_map(rusqlite::params_from_iter(params), |r| r.get(0)) + .unwrap() + .collect::>>() + .unwrap() + }) +} + +/// The ids the evaluator finds for `query`, sorted. +fn memory_ids(flows: &[Flow], query: &FlowQuery) -> Vec { + let matcher = query.matcher(); + let mut ids: Vec = + flows.iter().filter(|f| matcher.matches(&document(f))).map(|f| f.id.clone()).collect(); + ids.sort(); + ids +} + +/// Flows made for the tests below, each named by what sets it apart. +fn fixture() -> Vec { + let at = |minutes_ago: i64| (now() - Duration::minutes(minutes_ago)).naive_utc(); + let base = |id: &str, url: &str| Flow { + id: id.into(), + url: url.into(), + method: "GET".into(), + client_http_version: "HTTP/1.1".into(), + started_at: at(1), + ..Default::default() + }; + vec![ + Flow { + status: Some(200), + content_type: Some("application/json; charset=utf-8".into()), + request_headers: vec![header("Accept", "application/json")], + response_headers: vec![ + header("X-Cache", "HIT"), + header("Content-Type", "application/json"), + ], + response_size: 250_000, + request_size: 120, + elapsed_ms: Some(820), + client_http_version: "HTTP/2".into(), + client_name: Some("Google Chrome".into()), + client_bundle_id: Some("com.google.Chrome".into()), + client_path: Some("/Applications/Google Chrome.app".into()), + client_pid: Some(501), + saved_request_id: Some("rq_1".into()), + started_at: at(2), + ..base("fl_github", "https://api.github.com/repos/yaak?page=2&sort=Stars") + }, + Flow { + method: "POST".into(), + status: Some(404), + content_type: Some("text/html".into()), + request_headers: vec![header("Content-Type", "application/x-www-form-urlencoded")], + response_headers: vec![header("x-cache", "MISS")], + response_size: 900, + elapsed_ms: Some(40), + client_name: Some("curl".into()), + client_path: Some("/usr/bin/curl".into()), + client_cwd: Some("/Users/ada/src/fieldwork".into()), + started_at: at(30), + ..base("fl_stripe", "http://api.stripe.com:8080/v1/charges/ch_1") + }, + Flow { + status: Some(503), + error: None, + request_headers: vec![header("content-type", "application/json")], + response_size: 2_000_000, + elapsed_ms: Some(3_000), + client_name: Some("nsurlsessiond".into()), + client_path: Some("/usr/libexec/nsurlsessiond".into()), + client_cwd: Some("/".into()), + started_at: at(90), + ..base("fl_daemon", "https://github.com/login") + }, + Flow { + method: "CONNECT".into(), + tunnelled: true, + response_size: 5_000, + elapsed_ms: Some(10_000), + started_at: at(60 * 25), + ..base("fl_tunnel", "https://bank.example.com") + }, + Flow { + error: Some("connection refused".into()), + started_at: at(3), + ..base("fl_error", "http://localhost:3000/health") + }, + Flow { + started_at: at(0), + agent_read: true, + ..base("fl_pending", "https://example.com/slow") + }, + Flow { + status: Some(101), + stream_kind: Some(StreamKind::Websocket), + elapsed_ms: Some(50), + ..base("fl_ws", "wss://ws.example.com/socket") + }, + Flow { + method: "POST".into(), + status: Some(200), + stream_kind: Some(StreamKind::Grpc), + grpc_status: Some(5), + client_http_version: "HTTP/2".into(), + elapsed_ms: Some(12), + ..base("fl_grpc", "https://grpc.example.com/pkg.Service/Method") + }, + Flow { status: Some(508), elapsed_ms: Some(1), ..base("fl_loop", "http://neverssl.com/") }, + ] +} + +/// The fixture's ids `query` finds, after checking the database and the evaluator agree. +fn finds(query: &str) -> Vec { + finds_with(query, false) +} + +fn finds_with(query: &str, hide_system: bool) -> Vec { + let (_dir, db, flows) = db_of(fixture()); + let prepared = FlowQuery::at(query, hide_system, now()); + let sql = sql_ids(&db, &prepared); + assert_eq!(sql, memory_ids(&flows, &prepared), "the two disagree on {query:?}"); + sql +} + +fn ids(ids: &[&str]) -> Vec { + let mut ids: Vec = ids.iter().map(|id| id.to_string()).collect(); + ids.sort(); + ids +} + +#[test] +fn bare_words_search_url_method_status_and_type() { + assert_eq!(finds("GITHUB"), ids(&["fl_daemon", "fl_github"])); + assert_eq!(finds("post"), ids(&["fl_grpc", "fl_stripe"])); + assert_eq!(finds("404"), ids(&["fl_stripe"])); + assert_eq!(finds("html"), ids(&["fl_stripe"])); + assert_eq!(finds("\"api.github.com/repos\""), ids(&["fl_github"])); + assert_eq!(finds("github repos"), ids(&["fl_github"])); + // Headers are not free text + assert_eq!(finds("HIT"), ids(&[])); +} + +#[test] +fn url_fields() { + assert_eq!(finds("@host:api.github.com"), ids(&["fl_github"])); + assert_eq!(finds("@host:github"), ids(&[])); + assert_eq!(finds("@host:*github*"), ids(&["fl_daemon", "fl_github"])); + // A site is its domain and every host below it + assert_eq!(finds("@host:*.github.com"), ids(&["fl_daemon", "fl_github"])); + assert_eq!(finds("@host:*.example.com"), ids(&["fl_grpc", "fl_pending", "fl_tunnel", "fl_ws"])); + // A host on another port, with or without it + assert_eq!(finds("@host:api.stripe.com"), ids(&["fl_stripe"])); + assert_eq!(finds("@host:api.stripe.com:8080"), ids(&["fl_stripe"])); + assert_eq!(finds("@port:8080"), ids(&["fl_stripe"])); + assert_eq!(finds("@port:443 @scheme:wss"), ids(&["fl_ws"])); + assert_eq!(finds("@port:<1000 @scheme:http"), ids(&["fl_loop"])); + assert_eq!(finds("@scheme:http"), ids(&["fl_error", "fl_loop", "fl_stripe"])); + assert_eq!(finds("@path:/v1"), ids(&["fl_stripe"])); + assert_eq!(finds("@path:/v1/*"), ids(&["fl_stripe"])); + assert_eq!(finds("@path:/v1/charges/*"), ids(&["fl_stripe"])); + assert_eq!(finds("@path:/v1/charges/ch_1/*"), ids(&["fl_stripe"])); + assert_eq!(finds("@path:/v1/charge/*"), ids(&[])); + assert_eq!( + finds("@path:/"), + ids(&[ + "fl_daemon", + "fl_error", + "fl_github", + "fl_grpc", + "fl_loop", + "fl_pending", + "fl_stripe", + "fl_ws" + ]) + ); + assert_eq!(finds("@url:*page=2*"), ids(&["fl_github"])); + assert_eq!(finds("@query.page:2"), ids(&["fl_github"])); + assert_eq!(finds("@query.SORT:stars"), ids(&["fl_github"])); + assert_eq!(finds("@query.page:*"), ids(&["fl_github"])); + assert_eq!(finds("@query.page:>1"), ids(&["fl_github"])); + assert_eq!(finds("@query:stars"), ids(&["fl_github"])); + assert_eq!( + finds("-@query.page:*"), + ids(&[ + "fl_daemon", + "fl_error", + "fl_grpc", + "fl_loop", + "fl_pending", + "fl_stripe", + "fl_tunnel", + "fl_ws" + ]) + ); +} + +#[test] +fn request_and_response_fields() { + assert_eq!(finds("@method:post"), ids(&["fl_grpc", "fl_stripe"])); + assert_eq!(finds("@method:CONN*"), ids(&["fl_tunnel"])); + assert_eq!(finds("@type:json"), ids(&["fl_daemon", "fl_github"])); + // The request's only when the response has none + assert_eq!(finds("@type:form"), ids(&[])); + assert_eq!(finds("@proto:h2"), ids(&["fl_github", "fl_grpc"])); + assert_eq!(finds("@proto:ws"), ids(&["fl_ws"])); + assert_eq!(finds("@proto:grpc"), ids(&["fl_grpc"])); + assert_eq!(finds("@proto:h1 @method:post"), ids(&["fl_stripe"])); + assert_eq!(finds("@id:2"), ids(&["fl_stripe"])); + assert_eq!(finds("@id:>=8"), ids(&["fl_grpc", "fl_loop"])); +} + +#[test] +fn statuses() { + assert_eq!(finds("@status:404"), ids(&["fl_stripe"])); + assert_eq!(finds("@status:4xx"), ids(&["fl_stripe"])); + assert_eq!(finds("@status:5XX"), ids(&["fl_daemon", "fl_loop"])); + assert_eq!(finds("@status:>=400"), ids(&["fl_daemon", "fl_loop", "fl_stripe"])); + assert_eq!(finds("@status:[200 TO 299]"), ids(&["fl_github", "fl_grpc"])); + assert_eq!(finds("@status:50*"), ids(&["fl_daemon", "fl_loop"])); + assert_eq!( + finds("@status:*"), + ids(&[ + "fl_daemon", + "fl_github", + "fl_grpc", + "fl_loop", + "fl_stripe", + "fl_ws" + ]) + ); + assert_eq!(finds("-@status:*"), ids(&["fl_error", "fl_pending", "fl_tunnel"])); + assert_eq!(finds("@status:40"), ids(&[])); +} + +#[test] +fn sizes_and_durations_with_units() { + assert_eq!(finds("@size:>100kb"), ids(&["fl_daemon", "fl_github"])); + assert_eq!(finds("@size:>1mb"), ids(&["fl_daemon"])); + assert_eq!(finds("@res.size:[1kb TO 10kb]"), ids(&["fl_tunnel"])); + assert_eq!(finds("@req.size:120b"), ids(&["fl_github"])); + assert_eq!(finds("@size:>1.5gb"), ids(&[])); + assert_eq!(finds("@duration:>500ms"), ids(&["fl_daemon", "fl_github", "fl_tunnel"])); + assert_eq!(finds("@duration:>=3s"), ids(&["fl_daemon", "fl_tunnel"])); + assert_eq!(finds("@duration:<1m @duration:>1s"), ids(&["fl_daemon", "fl_tunnel"])); + // A unit the field does not take matches nothing + assert_eq!(finds("@duration:>1h"), ids(&[])); + assert_eq!(finds("-@duration:*"), ids(&["fl_error", "fl_pending"])); +} + +#[test] +fn times_ago_and_absolute() { + assert_eq!( + finds("@time:<5m"), + ids(&[ + "fl_error", + "fl_github", + "fl_grpc", + "fl_loop", + "fl_pending", + "fl_ws" + ]) + ); + assert_eq!(finds("@time:<=30m @time:>5m"), ids(&["fl_stripe"])); + assert_eq!(finds("@time:>1h"), ids(&["fl_daemon", "fl_tunnel"])); + assert_eq!(finds("@time:>1d"), ids(&["fl_tunnel"])); + assert_eq!(finds("@time:[1h TO 2h]"), ids(&["fl_daemon"])); + assert_eq!(finds("@time:<1w"), finds("@time:*")); + // An instant with its offset + assert_eq!(finds("@time:<2026-11-07T11:00:00Z"), ids(&["fl_daemon", "fl_tunnel"])); + assert_eq!( + finds("@time:>=2026-11-07T11:30:00Z @time:<2026-11-07T11:58:00Z"), + ids(&["fl_error", "fl_stripe"]) + ); + // Local: the day the fixture's now falls on holds all but the tunnel + let today = now().with_timezone(&Local).format("%Y-%m-%d").to_string(); + let yesterday_ish = finds(&format!("@time:<{today}")); + let on_day = finds(&format!("@time:{today}")); + assert_eq!(yesterday_ish.len() + on_day.len(), fixture().len(), "{yesterday_ish:?} {on_day:?}"); + assert!(yesterday_ish.contains(&"fl_tunnel".to_string())); + // A time today, local: the minute two minutes before now holds the GitHub flow + let minute = (now() - Duration::minutes(2)).with_timezone(&Local).format("%H:%M").to_string(); + let by_minute = finds(&format!("@time:{minute}")); + if (now() - Duration::minutes(2)).with_timezone(&Local).date_naive() + == now().with_timezone(&Local).date_naive() + { + assert_eq!(by_minute, ids(&["fl_github"])); + } + // A time that is none of these matches nothing + assert_eq!(finds("@time:soon"), ids(&[])); + assert_eq!(finds("@time:5m"), ids(&[])); +} + +#[test] +fn sources() { + assert_eq!(finds("@app:\"google chrome\""), ids(&["fl_github"])); + assert_eq!(finds("@app:com.google.Chrome"), ids(&["fl_github"])); + assert_eq!(finds("@app:chrome"), ids(&[])); + assert_eq!(finds("@app:*chrome*"), ids(&["fl_github"])); + // A program outside an app, by its name, its path or its folder + assert_eq!(finds("@app:curl"), ids(&["fl_stripe"])); + assert_eq!(finds("@app:/usr/bin/curl"), ids(&["fl_stripe"])); + assert_eq!(finds("@app:FIELDWORK"), ids(&["fl_stripe"])); + assert_eq!(finds("@cwd:fieldwork"), ids(&["fl_stripe"])); + assert_eq!(finds("@cwd:/Users/ada/src/fieldwork"), ids(&["fl_stripe"])); + assert_eq!(finds("@cwd:*"), ids(&["fl_daemon", "fl_stripe"])); + assert_eq!(finds("@pid:501"), ids(&["fl_github"])); +} + +#[test] +fn headers_on_either_side_or_one() { + assert_eq!(finds("@header.x-cache:hit"), ids(&["fl_github"])); + assert_eq!(finds("@header.X-CACHE:*"), ids(&["fl_github", "fl_stripe"])); + assert_eq!(finds("@res.header.x-cache:*"), ids(&["fl_github", "fl_stripe"])); + assert_eq!(finds("@req.header.x-cache:*"), ids(&[])); + assert_eq!(finds("@header.content-type:json"), ids(&["fl_daemon", "fl_github"])); + assert_eq!(finds("@req.header.content-type:json"), ids(&["fl_daemon"])); + assert_eq!(finds("@res.header.content-type:json"), ids(&["fl_github"])); + assert_eq!(finds("@header.accept:application/*"), ids(&["fl_github"])); + assert_eq!(finds("@header:miss"), ids(&["fl_stripe"])); + assert_eq!( + finds("-@header.x-cache:hit"), + ids(&[ + "fl_daemon", + "fl_error", + "fl_grpc", + "fl_loop", + "fl_pending", + "fl_stripe", + "fl_tunnel", + "fl_ws" + ]) + ); +} + +#[test] +fn flags() { + assert_eq!(finds("@is:system"), ids(&["fl_daemon"])); + assert_eq!(finds("@is:tunnelled"), ids(&["fl_tunnel"])); + assert_eq!(finds("@is:error"), ids(&["fl_error", "fl_grpc"])); + assert_eq!(finds("@is:pending"), ids(&["fl_pending"])); + assert_eq!(finds("@is:saved"), ids(&["fl_github"])); + assert_eq!(finds("@is:read"), ids(&["fl_pending"])); + assert_eq!(finds("@is:loop"), ids(&["fl_loop"])); + assert_eq!(finds("@is:ws"), ids(&["fl_ws"])); + assert_eq!(finds("@is:sse"), ids(&[])); + assert_eq!(finds("@is:GRPC"), ids(&["fl_grpc"])); + assert_eq!( + finds("@is:*"), + ids(&[ + "fl_daemon", + "fl_error", + "fl_github", + "fl_grpc", + "fl_loop", + "fl_pending", + "fl_tunnel", + "fl_ws" + ]) + ); + assert_eq!(finds("@is:s*"), ids(&["fl_daemon", "fl_github"])); + // Not yet, and not a flag + assert_eq!(finds("@is:marked"), ids(&[])); + assert_eq!(finds("@is:nope"), ids(&[])); + assert_eq!(finds("-@is:nope").len(), fixture().len()); +} + +#[test] +fn groups_and_negation() { + assert_eq!(finds("@status:4xx OR @is:tunnelled"), ids(&["fl_stripe", "fl_tunnel"])); + assert_eq!(finds("(@status:4xx OR @is:tunnelled) -@method:connect"), ids(&["fl_stripe"])); + assert_eq!(finds("-(@status:* OR @is:pending)"), ids(&["fl_error", "fl_tunnel"])); + assert_eq!(finds("github -@host:github.com"), ids(&["fl_github"])); + assert_eq!( + finds("@host:*.example.com AND -@is:ws"), + ids(&["fl_grpc", "fl_pending", "fl_tunnel"]) + ); +} + +#[test] +fn bodies_and_unknown_fields_match_nothing() { + assert_eq!(finds("@body:x"), ids(&[])); + assert_eq!(finds("@res.body:*"), ids(&[])); + assert_eq!(finds("@nope:x"), ids(&[])); + assert_eq!(finds("-@body:x").len(), fixture().len()); +} + +#[test] +fn hiding_system() { + let all = fixture().len(); + assert_eq!(finds_with("", true).len(), all - 1); + assert!(!finds_with("", true).contains(&"fl_daemon".to_string())); + assert_eq!(finds_with("github", true), ids(&["fl_github"])); + // The search names System itself, either way + assert_eq!(finds_with("@is:system", true), ids(&["fl_daemon"])); + assert_eq!(finds_with("github OR @is:System", true), ids(&["fl_daemon", "fl_github"])); + assert_eq!(finds_with("-@is:system", true).len(), all - 1); + assert!(FlowQuery::new("", true).matcher().matches(&document(&fixture()[0]))); + assert!(!FlowQuery::new("", true).is_empty()); + assert!(FlowQuery::new(" ", false).is_empty()); +} + +#[test] +fn diagnostics_mark_what_cannot_run() { + let kinds = + |q: &str| diagnostics(q).into_iter().map(|d| (d.kind, d.start, d.end)).collect::>(); + use QueryDiagnosticKind::*; + assert_eq!(kinds("@status:4xx @time:<5m @host:*.a.com"), vec![]); + assert_eq!(kinds("@nope:x"), vec![(UnknownField, 0, 6)]); + assert_eq!(kinds("@size:>5parsecs"), vec![(InvalidValue, 6, 15)]); + assert_eq!(kinds("@time:soon"), vec![(InvalidValue, 6, 10)]); + assert_eq!(kinds("@is:nope"), vec![(InvalidValue, 4, 8)]); + assert_eq!(kinds("@is:marked"), vec![(NotYet, 0, 10)]); + assert_eq!(kinds("x @body:token"), vec![(NotYet, 2, 13)]); + // Spans count UTF-16 units + assert_eq!(kinds("é😀 @nope:x"), vec![(UnknownField, 4, 10)]); + // Typing a field is not yet a mistake + assert_eq!(kinds("@is:"), vec![]); + assert_eq!(kinds("@time:"), vec![]); +} + +#[test] +fn time_is_compared_as_stored() { + // The database compares `started_at` as text: what this writes must be how it is stored. + let (_dir, db, flows) = db_of(fixture()); + let stored: Vec = db.with_conn(|c| { + let conn = c.conn().resolve(); + let mut stmt = conn.prepare("SELECT started_at FROM flows ORDER BY rowid").unwrap(); + stmt.query_map([], |r| r.get(0)).unwrap().map(|r| r.unwrap()).collect() + }); + for (flow, stored) in flows.iter().zip(stored) { + assert_eq!(stored_time(started_ms(&flow.started_at)), stored); + } + let precise = + NaiveDateTime::parse_from_str("2026-11-07 10:00:00.123456", "%Y-%m-%d %H:%M:%S%.f") + .unwrap(); + assert_eq!(stored_time(started_ms(&precise)), "2026-11-07 10:00:00.123456"); +} + +#[test] +fn reads_url_parts() { + assert_eq!(url_hostname("https://user:pw@API.Example.com:8443/x"), "api.example.com"); + assert_eq!(url_host("https://API.Example.com:8443/x"), "api.example.com:8443"); + assert_eq!(url_hostname("http://[::1]:8080/"), "[::1]"); + assert_eq!(url_port("http://[::1]:8080/"), Some(8080)); + assert_eq!(url_port("https://a.com/"), Some(443)); + assert_eq!(url_port("bank.example.com:443"), Some(443)); + assert_eq!(url_scheme("bank.example.com:443"), None); + assert_eq!(url_path("https://a.com/x/y?q=1#f"), Some("/x/y")); + assert_eq!(url_path("https://a.com?q=1"), Some("")); + assert_eq!(url_sidebar_path("https://a.com/v1//users/"), Some("/v1/users".into())); + assert_eq!(url_sidebar_path("https://a.com/v1/users"), None); + assert_eq!(url_sidebar_path("https://a.com"), None); + assert_eq!( + url_query("https://a.com/?A=1&b=%20two&a=3#f"), + vec![ + ("a".to_string(), "1".to_string()), + ("b".to_string(), " two".to_string()), + ("a".to_string(), "3".to_string()), + ] + ); +} + +// -- The drift guard -- + +/// A small deterministic generator, so a failure names a flow that comes back every run. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } + + fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T { + &items[self.next() as usize % items.len()] + } + + fn chance(&mut self, percent: u64) -> bool { + self.next() % 100 < percent + } +} + +/// Flows as varied as the fields can tell apart: hosts with and without ports and schemes, +/// queries and their encodings, any case, text outside ASCII, every flag, and the empty cases. +fn sample(count: usize) -> Vec { + let mut rng = Rng(7); + let hosts = [ + "https://api.github.com", + "https://github.com", + "http://API.Stripe.com:8080", + "https://api.stripe.community", + "http://localhost:3000", + "https://[::1]:8443", + "wss://ws.example.com", + "https://café.example.org", + "http://neverssl.com:80", + "https://user:pw@intranet.local", + "bank.example.com:443", + ]; + let paths = [ + "", + "/", + "/v1/charges", + "/v1/charges/ch_1", + "/v1//users", + "/repos/yaak", + "/ÜBER/x", + "/a%20b", + ]; + let queries = [ + "", + "?page=2", + "?page=10&sort=Stars", + "?q=%C3%A9t%C3%A9", + "?a=1&a=2", + "?flag", + "?page=two", + ]; + let methods = ["GET", "POST", "put", "DELETE", "CONNECT", "HEAD"]; + let statuses = [ + None, + Some(101), + Some(200), + Some(204), + Some(301), + Some(404), + Some(500), + Some(503), + Some(508), + ]; + let types = [ + None, + Some("application/json"), + Some("Application/JSON; charset=UTF-8"), + Some("text/html"), + Some("image/png"), + Some(""), + ]; + let header_names = [ + "Content-Type", + "accept", + "X-Cache", + "x-trace", + "Set-Cookie", + "ÉTAG", + ]; + let header_values = [ + "application/json", + "HIT", + "miss", + "", + "a=1; Path=/", + "200", + "Wörld", + "text/*", + ]; + // Name, bundle id, path and working directory + type App<'a> = (Option<&'a str>, Option<&'a str>, Option<&'a str>, Option<&'a str>); + let apps: [App; 7] = [ + (None, None, None, None), + ( + Some("Google Chrome"), + Some("com.google.Chrome"), + Some("/Applications/Google Chrome.app"), + None, + ), + (Some("curl"), None, Some("/usr/bin/curl"), Some("/Users/ada/src/fieldwork")), + (Some("node"), None, Some("/opt/homebrew/bin/node"), Some("/Users/ada/src/Ledger/")), + (Some("nsurlsessiond"), None, Some("/usr/libexec/nsurlsessiond"), Some("/")), + (Some("trustd"), None, Some("/somewhere/trustd"), None), + ( + Some("Slack"), + Some("com.tinyspeck.slackmacgap"), + Some("/Applications/Slack.app"), + Some("/tmp"), + ), + ]; + let versions = ["HTTP/1.1", "HTTP/2", "HTTP/1.0", ""]; + let streams = [ + None, + None, + None, + Some(StreamKind::Websocket), + Some(StreamKind::Sse), + Some(StreamKind::Grpc), + Some(StreamKind::GrpcWeb), + ]; + let sizes = [0, 1, 999, 1000, 1001, 250_000, 1_000_000, 2_500_000_000]; + let elapsed = [ + None, + Some(0), + Some(12), + Some(500), + Some(501), + Some(1500), + Some(60_000), + Some(61_000), + ]; + let ages_s = [ + 0, 30, 59, 60, 61, 299, 300, 301, 3599, 3600, 7200, 86_399, 86_400, 90_000, 700_000, + ]; + + (0..count) + .map(|i| { + let tunnelled = rng.chance(8); + let url = if tunnelled { + rng.pick(&["bank.example.com:443", "https://bank.example.com"]).to_string() + } else { + format!("{}{}{}", rng.pick(&hosts), rng.pick(&paths), rng.pick(&queries)) + }; + let headers = |rng: &mut Rng| { + (0..rng.next() % 4) + .map(|_| { + header(rng.pick::<&str>(&header_names), rng.pick::<&str>(&header_values)) + }) + .collect::>() + }; + let (name, bundle, path, cwd) = *rng.pick(&apps); + let age = *rng.pick(&ages_s) as i64; + let started_at = (now() + - Duration::seconds(age) + - Duration::microseconds((rng.next() % 3) as i64 * 250)) + .naive_utc(); + Flow { + id: format!("fl_{i:03}"), + url, + method: rng.pick(&methods).to_string(), + status: *rng.pick(&statuses), + content_type: rng.pick(&types).map(String::from), + request_headers: headers(&mut rng), + response_headers: headers(&mut rng), + request_size: *rng.pick(&sizes), + response_size: *rng.pick(&sizes), + elapsed_ms: *rng.pick(&elapsed), + started_at, + client_http_version: rng.pick(&versions).to_string(), + client_name: name.map(String::from), + client_bundle_id: bundle.map(String::from), + client_path: path.map(String::from), + client_cwd: cwd.map(String::from), + client_pid: rng.chance(50).then(|| (rng.next() % 1000) as i32), + stream_kind: *rng.pick(&streams), + grpc_status: *rng.pick(&[None, Some(0), Some(5)]), + error: rng.chance(15).then(|| "connection reset".to_string()), + cancelled: rng.chance(10), + saved_request_id: rng.chance(15).then(|| "rq_x".to_string()), + tunnelled, + agent_read: rng.chance(20), + ..Default::default() + } + }) + .collect() +} + +/// Queries over every field kind and value shape, and the ways they combine. +const BATTERY: &[&str] = &[ + "", + "github", + "GITHUB stripe", + "json", + "post", + "50", + "é", + "café", + "\"api.github.com/repos\"", + "\"GET\"", + "%", + "_", + "@host:api.github.com", + "@host:API.STRIPE.COM", + "@host:api.stripe.com:8080", + "@host:*.github.com", + "@host:*.example.com", + "@host:*stripe*", + "@host:café.example.org", + "@host:[::1]", + "@host:*", + "-@host:*.github.com", + "@port:443", + "@port:>=8000", + "@port:[80 TO 3000]", + "@port:*", + "-@port:*", + "@scheme:https", + "@scheme:ws*", + "-@scheme:*", + "@path:/v1", + "@path:/v1/*", + "@path:/v1/charges/*", + "@path:\"/v1/charges\"", + "@path:*users*", + "@path:/über/*", + "@path:/", + "@path:*", + "@url:*page=*", + "@url:\"%20\"", + "@query.page:2", + "@query.page:>3", + "@query.page:[1 TO 5]", + "@query.page:*", + "@query.sort:STARS", + "@query.q:été", + "@query.a:2", + "@query.flag:*", + "@query:2", + "-@query:*", + "@method:get", + "@method:P*", + "@method:*", + "@type:json", + "@type:application/json", + "@type:*json*", + "@type:*", + "-@type:*", + "@proto:h1", + "@proto:h2", + "@proto:ws", + "@proto:grpc", + "@proto:sse", + "-@proto:*", + "@id:7", + "@id:>20", + "@id:[5 TO 9]", + "@status:200", + "@status:2xx", + "@status:5XX", + "@status:1xx", + "@status:>=400", + "@status:<300", + "@status:[300 TO 499]", + "@status:50*", + "@status:*", + "-@status:*", + "@status:\"50\"", + "@status:abc", + "@status:4xy", + "@size:>100kb", + "@size:1000", + "@size:1kb", + "@size:>=1mb", + "@size:[1b TO 1kb]", + "@size:>2gb", + "@req.size:<1kb", + "@res.size:*", + "@size:>5parsecs", + "@duration:>500ms", + "@duration:500", + "@duration:>=1.5s", + "@duration:[1s TO 1m]", + "@duration:*", + "-@duration:*", + "@duration:>1h", + "@time:<5m", + "@time:<=1m", + "@time:>1h", + "@time:>=1d", + "@time:[1m TO 1h]", + "@time:[1h TO 1m]", + "@time:<500ms", + "@time:*", + "@time:<2026-11-07T11:00:00Z", + "@time:>2026-11-07T11:59:00Z", + "@time:2026-11-07T11:59:59Z", + "@time:2026-11-07", + "@time:<2026-11-07", + "@time:>=2026-11-06", + "@time:11:59", + "@time:>11:00", + "@time:<=11:59:30", + "@time:soon", + "@app:\"google chrome\"", + "@app:com.google.Chrome", + "@app:curl", + "@app:fieldwork", + "@app:ledger", + "@app:/usr/bin/curl", + "@app:*", + "@app:*o*", + "-@app:*", + "@cwd:fieldwork", + "@cwd:/tmp", + "@cwd:*", + "@cwd:/", + "@pid:>500", + "@pid:*", + "@header.content-type:json", + "@header.Content-Type:*", + "@header.x-cache:hit", + "@header.x-cache:*", + "-@header.x-cache:*", + "@header.accept:text/*", + "@header.étag:wörld", + "@header.set-cookie:\"Path=/\"", + "@header:miss", + "@header:*", + "@header:200", + "@header.x-trace:>100", + "@req.header.content-type:json", + "@res.header.x-cache:*", + "@res.header:hit", + "-@req.header:*", + "@is:system", + "-@is:system", + "@is:tunnelled", + "@is:error", + "@is:pending", + "@is:saved", + "@is:read", + "@is:loop", + "@is:ws", + "@is:sse", + "@is:grpc", + "@is:*", + "-@is:*", + "@is:s*", + "@is:marked", + "@is:nope", + "@body:x", + "-@res.body:*", + "@nope:x", + "-@nope:x", + "@status:4xx OR @is:tunnelled", + "(@status:4xx OR @is:error) -@method:post", + "-(@status:* OR @is:pending)", + "github -@host:github.com", + "@host:*.example.com AND -@is:ws", + "(@app:curl OR @app:node) (@host:*.github.com OR @path:/v1/*)", + "@app:Slack @host:a.com OR @app:Chrome @host:api.github.com", + "-(-@is:system)", + "json @size:>1kb OR @duration:<100ms @proto:h2", +]; + +#[test] +fn the_database_and_the_evaluator_find_the_same_flows() { + let (_dir, db, flows) = db_of(sample(400)); + let mut checked = 0; + for query in BATTERY { + for hide_system in [false, true] { + let prepared = FlowQuery::at(query, hide_system, now()); + let sql = sql_ids(&db, &prepared); + let memory = memory_ids(&flows, &prepared); + if sql != memory { + let only_sql: Vec<_> = sql.iter().filter(|id| !memory.contains(id)).collect(); + let only_memory: Vec<_> = memory.iter().filter(|id| !sql.contains(id)).collect(); + let example = only_sql + .first() + .or(only_memory.first()) + .map(|id| flows.iter().find(|f| &&f.id == id).cloned()); + panic!( + "{query:?} (hide_system {hide_system}): only in SQL {only_sql:?}, \ + only in memory {only_memory:?}; e.g. {example:#?}" + ); + } + checked += 1; + } + } + assert_eq!(checked, BATTERY.len() * 2); + // The battery means something only if the sample spreads its answers. + let spread = BATTERY + .iter() + .map(|q| sql_ids(&db, &FlowQuery::at(q, false, now())).len()) + .filter(|n| *n > 0 && *n < flows.len()) + .count(); + assert!(spread > BATTERY.len() * 2 / 3, "only {spread} queries split the sample"); +} + +/// Live flows are matched as the list is: a flow's event and a listing agree. +#[test] +fn live_search_matches_as_the_list_does() { + let live = LiveSearch::default(); + let flow = fixture().remove(0); + assert_eq!(live.check(&flow), None); + live.watch(Some(("k1".into(), "@status:2xx @host:*.github.com".into(), false))); + assert_eq!(live.check(&flow), Some(SearchMatch { key: "k1".into(), matches: true })); + live.watch(Some(("k2".into(), "@status:4xx".into(), false))); + let payload = live.payload(flow, true); + assert_eq!(payload.search, Some(SearchMatch { key: "k2".into(), matches: false })); + live.watch(None); + assert_eq!(live.check(&payload.model), None); +} + +#[test] +fn seen_values_for_completion() { + let (_dir, db, _) = db_of(fixture()); + let values = |path: &str| seen_values(&db, path).unwrap(); + let mut hosts = values("host"); + hosts.sort(); + assert!(hosts.contains(&"api.stripe.com:8080".to_string()), "{hosts:?}"); + assert_eq!(values("method").len(), 3); + assert!(values("status").contains(&"404".to_string())); + let types = values("type"); + assert!(types.contains(&"application/json".to_string()), "{types:?}"); + assert!(types.contains(&"text/html".to_string()), "{types:?}"); + assert_eq!(values("cwd"), vec!["fieldwork".to_string()]); + assert!(values("app").contains(&"Google Chrome".to_string())); + assert_eq!(values("is"), FLAGS.iter().map(|f| f.to_string()).collect::>()); + let mut names = values("header"); + names.sort(); + assert_eq!(names, vec!["accept", "content-type", "x-cache"]); + let mut response_names = values("res.header"); + response_names.sort(); + assert_eq!(response_names, vec!["content-type", "x-cache"]); + let mut cache = values("header.X-Cache"); + cache.sort(); + assert_eq!(cache, vec!["HIT", "MISS"]); + // Near-unique fields offer nothing + for path in [ + "url", + "path", + "id", + "pid", + "size", + "duration", + "time", + "query", + "query.page", + ] { + assert!(values(path).is_empty(), "{path}"); + } +} + +#[test] +fn a_header_with_many_values_offers_none() { + let flows = (0..25) + .map(|i| Flow { + id: format!("fl_{i}"), + url: "https://a.com/".into(), + response_headers: vec![ + header("Date", &format!("day {i}")), + header("Server", "nginx"), + ], + ..Default::default() + }) + .collect(); + let (_dir, db, _) = db_of(flows); + assert!(seen_values(&db, "header.date").unwrap().is_empty()); + assert_eq!(seen_values(&db, "res.header.server").unwrap(), vec!["nginx"]); +} + +/// Completion shows each field with what it is and a term using it, and every such term runs. +#[test] +fn fields_have_descriptions_and_examples_that_run() { + for field in &schema().fields { + assert!(field.info.as_deref().is_some_and(|i| !i.is_empty()), "{}", field.name); + if FIELDS_NOT_YET.contains(&field.name.as_str()) { + continue; + } + let example = field.example.as_deref().unwrap_or_else(|| panic!("{}", field.name)); + assert!(example.contains(&format!("@{}", field.name)), "{example}"); + assert_eq!(diagnostics(example), vec![], "{example}"); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/saved_queries.rs b/crates-proxy/yaak-proxy-lib/src/saved_queries.rs new file mode 100644 index 000000000..7524e86be --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/saved_queries.rs @@ -0,0 +1,181 @@ +//! Searches saved by name, which the sidebar lists with a count each. + +use crate::db::ProxyQueryManager; +use crate::flows::{self, FlowFilter}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use yaak_database::generate_prefixed_id; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct SavedQuery { + pub id: String, + pub name: String, + /// The search, as the search box takes it. + pub query: String, + /// Where it sits in the list, lowest first. + #[ts(type = "number")] + pub sort_order: i64, +} + +/// How many flows a saved query covers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SavedQueryCount { + pub id: String, + #[ts(type = "number")] + pub count: usize, +} + +fn failed(what: &str, e: impl std::fmt::Display) -> String { + format!("Failed to {what} the saved search: {e}") +} + +fn read(row: &rusqlite::Row) -> rusqlite::Result { + Ok(SavedQuery { + id: row.get("id")?, + name: row.get("name")?, + query: row.get("query")?, + sort_order: row.get("sort_order")?, + }) +} + +/// Every saved query, in order. None for a database from before them, read without migrating. +pub fn list(db: &ProxyQueryManager) -> Result, String> { + db.with_conn(|c| { + let conn = c.conn().resolve(); + let Ok(mut stmt) = + conn.prepare("SELECT * FROM saved_queries ORDER BY sort_order, created_at, id") + else { + return Ok(Vec::new()); + }; + stmt.query_map([], read) + .and_then(|rows| rows.collect::>>()) + .map_err(|e| failed("list", e)) + }) +} + +fn name_of(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("A saved search needs a name".into()); + } + Ok(name.to_string()) +} + +/// Saves `query` as `name`, after the others. +pub fn create(db: &ProxyQueryManager, name: &str, query: &str) -> Result { + let name = name_of(name)?; + let query = query.trim(); + if query.is_empty() { + return Err("Nothing to save".into()); + } + let id = generate_prefixed_id("sq"); + db.with_tx(|c| { + let conn = c.conn().resolve(); + conn.query_row( + "INSERT INTO saved_queries (id, name, query, sort_order) \ + VALUES (?1, ?2, ?3, (SELECT coalesce(max(sort_order), -1) + 1 FROM saved_queries)) \ + RETURNING *", + rusqlite::params![id, name, query], + read, + ) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("save", e)) +} + +pub fn rename(db: &ProxyQueryManager, id: &str, name: &str) -> Result { + let name = name_of(name)?; + db.with_tx(|c| { + c.conn() + .resolve() + .query_row( + "UPDATE saved_queries SET name = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?1 \ + RETURNING *", + rusqlite::params![id, name], + read, + ) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("rename", e)) +} + +/// Deletes one. False when there was none. +pub fn delete(db: &ProxyQueryManager, id: &str) -> Result { + db.with_tx(|c| { + c.conn() + .resolve() + .execute("DELETE FROM saved_queries WHERE id = ?", [id]) + .map(|n| n > 0) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("delete", e)) +} + +/// Each saved query's count of flows, with System's left out when `hide_system`, as the list +/// would show it, read from one snapshot. +pub fn counts(db: &ProxyQueryManager, hide_system: bool) -> Result, String> { + let saved = list(db)?; + let filters: Vec = saved + .iter() + .map(|s| FlowFilter { hide_system, ..FlowFilter::search(s.query.clone()) }) + .collect(); + let counts = flows::count_each(db, &filters)?; + Ok(saved + .into_iter() + .zip(counts) + .map(|(s, count)| SavedQueryCount { id: s.id, count }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saves_renames_counts_and_deletes() { + use crate::models::Flow; + use yaak_database::UpdateSource; + let dir = tempfile::tempdir().unwrap(); + let db = + ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap(); + for (i, status) in [200, 404, 500].into_iter().enumerate() { + let flow = Flow { + id: format!("fl_{i}"), + url: format!("https://example.com/{i}"), + status: Some(status), + ..Default::default() + }; + db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap(); + } + + let errors = create(&db, " Errors ", "@status:>=400").unwrap(); + let ok = create(&db, "OK", "@status:2xx").unwrap(); + assert_eq!((errors.name.as_str(), errors.sort_order, ok.sort_order), ("Errors", 0, 1)); + assert!(create(&db, " ", "x").is_err()); + assert!(create(&db, "x", " ").is_err()); + + let renamed = rename(&db, &ok.id, "Fine").unwrap(); + assert_eq!(renamed.name, "Fine"); + assert_eq!( + list(&db).unwrap().iter().map(|s| s.name.as_str()).collect::>(), + ["Errors", "Fine"] + ); + + let counts = counts(&db, false).unwrap(); + assert_eq!( + counts, + vec![ + SavedQueryCount { id: errors.id.clone(), count: 2 }, + SavedQueryCount { id: ok.id.clone(), count: 1 }, + ] + ); + + assert!(delete(&db, &errors.id).unwrap()); + assert!(!delete(&db, &errors.id).unwrap()); + assert_eq!(list(&db).unwrap(), vec![renamed]); + } +} diff --git a/crates-proxy/yaak-proxy-lib/tests/at_rest.rs b/crates-proxy/yaak-proxy-lib/tests/at_rest.rs index 3c5dd487c..65cb8f72a 100644 --- a/crates-proxy/yaak-proxy-lib/tests/at_rest.rs +++ b/crates-proxy/yaak-proxy-lib/tests/at_rest.rs @@ -267,17 +267,13 @@ fn search_reaches_inside_the_encrypted_database() { let db = ctx.db().unwrap(); // Query strings are searchable, being inside the encrypted file. - let q = |q: &str| FlowFilter { q: Some(q.into()), limit: Some(50), ..Default::default() }; + let q = |q: &str| FlowFilter { limit: Some(50), ..FlowFilter::search(q) }; let (page, total, matching) = flows::page(&db, &q(QUERY_SECRET)).unwrap(); assert_eq!((page.len(), total, matching), (50, FLOWS, Some(FLOWS))); let (page, _, matching) = flows::page(&db, &q("items/2999")).unwrap(); assert_eq!((page[0].id.as_str(), matching), ("fl_02999", Some(1))); - let posts_not_found = FlowFilter { - method: Some("post".into()), - status: Some("4xx".into()), - limit: Some(1), - ..Default::default() - }; + let posts_not_found = + FlowFilter { limit: Some(1), ..FlowFilter::search("@method:post @status:4xx") }; let (_, _, matching) = flows::page(&db, &posts_not_found).unwrap(); assert_eq!(matching, Some((0..FLOWS).filter(|i| i % 3 == 0 && i % 10 == 0).count())); @@ -295,11 +291,11 @@ fn search_reaches_inside_the_encrypted_database() { } assert_eq!(seen, FLOWS); - // The sidebar's site and path filters, which run the database's own functions. - let under = |u: &str| FlowFilter { under: Some(vec![u.into()]), ..Default::default() }; - assert_eq!(flows::list(&db, &under("api.example.com")).unwrap().len(), FLOWS); - assert_eq!(flows::list(&db, &under("*.example.com")).unwrap().len(), FLOWS); - assert_eq!(flows::list(&db, &under("api.example.com/v1/items/42")).unwrap().len(), 1); + // The sidebar's host, site and path terms, which run the database's own functions. + let found = |q: &str| flows::list(&db, &FlowFilter::search(q)).unwrap().len(); + assert_eq!(found("@host:api.example.com"), FLOWS); + assert_eq!(found("@host:*.example.com"), FLOWS); + assert_eq!(found("@host:api.example.com @path:/v1/items/42/*"), 1); // A full-text table, as body search will want, made and searched in the encrypted file. let hits: i64 = db diff --git a/crates-proxy/yaak-proxy-lib/tests/control.rs b/crates-proxy/yaak-proxy-lib/tests/control.rs index 65185eaff..7290e1ef1 100644 --- a/crates-proxy/yaak-proxy-lib/tests/control.rs +++ b/crates-proxy/yaak-proxy-lib/tests/control.rs @@ -144,14 +144,18 @@ async fn round_trip() { assert_eq!(flows.iter().map(|f| &f.id).collect::>(), vec![&flow.id]); let following = FlowFilter { after: Some(flow.id.clone()), ..Default::default() }; assert!(client.flows(&following).await.unwrap().is_empty()); - let filter = FlowFilter { status: Some("4xx".into()), ..Default::default() }; + let filter = FlowFilter::search("@status:4xx"); assert!(client.flows(&filter).await.unwrap().is_empty()); - let search = FlowFilter { q: Some("CHARGES post 2xx".into()), ..Default::default() }; + let search = FlowFilter::search("CHARGES post @status:2xx"); assert_eq!(client.flows(&search).await.unwrap().len(), 1); - let search = FlowFilter { q: Some("charges get".into()), ..Default::default() }; + let search = FlowFilter::search("charges @method:get"); assert!(client.flows(&search).await.unwrap().is_empty()); + // Listing is not reading; an agent reading the flow marks it. + let read = FlowFilter::search("@is:read"); + assert!(client.flows(&read).await.unwrap().is_empty()); let detail = client.flow(&flow.id, 8).await.unwrap(); + assert_eq!(client.flows(&read).await.unwrap().len(), 1); assert_eq!(detail.flow.method, "POST"); let request_body = detail.request_body.unwrap(); assert_eq!((request_body.data.as_str(), request_body.truncated), (r#"{"amount"#, true)); @@ -283,7 +287,7 @@ async fn offline_reads_proxy_db() { assert_eq!(detail.response_body.unwrap().data, r#"{"id":"ch_1","amount":42}"#); let body = connection.body(&flow.id, FlowBody::Request).await.unwrap(); assert_eq!(body.data, br#"{"amount":42}"#); - let posts = FlowFilter { method: Some("post".into()), ..Default::default() }; + let posts = FlowFilter::search("@method:post"); assert_eq!(connection.har(&posts).await.unwrap().log.entries.len(), 1); let empty = tempfile::tempdir().unwrap(); @@ -602,7 +606,7 @@ async fn export_har_writes_the_flows_asked_for() { assert_eq!(read(&selected)["log"]["entries"].as_array().unwrap().len(), 1); let searched = dir.path().join("searched.har"); - let filter = json!({ "q": "charges", "limit": 1 }); + let filter = json!({ "query": "charges", "limit": 1 }); let written = rpc(&ctx, "export_har", json!({ "path": searched, "filter": filter })).await.unwrap(); // The whole search, not the page the list had loaded. @@ -613,7 +617,7 @@ async fn export_har_writes_the_flows_asked_for() { let none = dir.path().join("none.har"); let written = - rpc(&ctx, "export_har", json!({ "path": none, "filter": { "q": "nothing-matches" } })) + rpc(&ctx, "export_har", json!({ "path": none, "filter": { "query": "nothing-matches" } })) .await .unwrap(); assert_eq!(written, 0); @@ -657,7 +661,7 @@ async fn flows_name_the_app_that_sent_them() { assert_eq!(flow.client_path.as_deref(), Some("/usr/bin/curl")); // The search and the sidebar's Apps group find it by its path. - let filter = FlowFilter { app: Some(vec!["/usr/bin/curl".into()]), ..Default::default() }; + let filter = FlowFilter::search("@app:/usr/bin/curl"); assert_eq!(yaak_proxy_lib::flows::list(&ctx.db().unwrap(), &filter).unwrap().len(), 1); } diff --git a/crates-proxy/yaak-proxy-lib/tests/flow.rs b/crates-proxy/yaak-proxy-lib/tests/flow.rs index 612183589..b08109fc1 100644 --- a/crates-proxy/yaak-proxy-lib/tests/flow.rs +++ b/crates-proxy/yaak-proxy-lib/tests/flow.rs @@ -502,10 +502,10 @@ async fn a_tunnelled_connect_is_recorded_with_its_sizes() { assert_eq!(done.error, None); assert!(done.request_headers.is_empty() && done.response_headers.is_empty()); - let tunnelled = rpc(&ctx, "list_flows", json!({"tunnelled": true})).await.unwrap(); + let tunnelled = rpc(&ctx, "list_flows", json!({"query": "@is:tunnelled"})).await.unwrap(); assert_eq!(tunnelled["flows"].as_array().unwrap().len(), 1); assert_eq!(tunnelled["flows"][0]["tunnelled"], true); - let decrypted = rpc(&ctx, "list_flows", json!({"tunnelled": false})).await.unwrap(); + let decrypted = rpc(&ctx, "list_flows", json!({"query": "-@is:tunnelled"})).await.unwrap(); assert_eq!(decrypted["flows"].as_array().unwrap().len(), 0); } diff --git a/crates-proxy/yaak-proxy-lib/tests/streams.rs b/crates-proxy/yaak-proxy-lib/tests/streams.rs index b34184fa0..d2a25040c 100644 --- a/crates-proxy/yaak-proxy-lib/tests/streams.rs +++ b/crates-proxy/yaak-proxy-lib/tests/streams.rs @@ -370,7 +370,7 @@ async fn grpc_web_calls_are_stored_and_decoded() { (failed.grpc_status, failed.grpc_message.as_deref()), (Some(12), Some("not here")) ); - let errors = rpc(&ctx, "list_flows", json!({ "status": "error" })).await.unwrap(); + let errors = rpc(&ctx, "list_flows", json!({ "query": "@is:error" })).await.unwrap(); let ids: Vec<_> = errors["flows"].as_array().unwrap().iter().map(|f| f["id"].clone()).collect(); assert_eq!(ids, vec![json!(failed.id)], "a non-zero status is a failed call"); diff --git a/crates/yaak-query/bindings/query.ts b/crates/yaak-query/bindings/query.ts index a9efad919..b64cf5d0d 100644 --- a/crates/yaak-query/bindings/query.ts +++ b/crates/yaak-query/bindings/query.ts @@ -10,7 +10,15 @@ label: string, /** * What replaces the span: `@method:`, `@header.`, or a value quoted when it needs to be. */ -insert: string, kind: CompletionKind, info: string | null, +insert: string, kind: CompletionKind, +/** + * What a field is, from its definition. + */ +info: string | null, +/** + * A term using the field, from its definition. + */ +example: string | null, /** * The term is not finished after inserting (a field still needs its value, a map field its * key), so completion should open again. @@ -57,9 +65,13 @@ name: string, kind: FieldKind, */ map?: boolean, /** - * A short note shown with the field in completions. + * What the field is, in a few words, shown with it in completions: `GET, POST, …`. */ -info?: string, }; +info?: string, +/** + * A term using it, shown with it in completions: `@method:post`. + */ +example?: string, }; export type FieldKind = { "type": "text" } | { "type": "number", units: Array, } | { "type": "flag" }; diff --git a/crates/yaak-query/pkg/yaak_query_bg.wasm b/crates/yaak-query/pkg/yaak_query_bg.wasm index c71e7d627d96d264cd5b1696e568d644dd54f9ad..2ad25df49c147b4d9400f72174bd26f14ef57ea8 100644 GIT binary patch delta 73910 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 delta 74912 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 diff --git a/crates/yaak-query/src/complete.rs b/crates/yaak-query/src/complete.rs index 9849c50c2..4c68b45e5 100644 --- a/crates/yaak-query/src/complete.rs +++ b/crates/yaak-query/src/complete.rs @@ -25,7 +25,10 @@ pub struct Completion { /// What replaces the span: `@method:`, `@header.`, or a value quoted when it needs to be. pub insert: String, pub kind: CompletionKind, + /// What a field is, from its definition. pub info: Option, + /// A term using the field, from its definition. + pub example: Option, /// The term is not finished after inserting (a field still needs its value, a map field its /// key), so completion should open again. pub reopen: bool, @@ -115,6 +118,7 @@ fn missing_at(word: &str, typed: &str, schema: &Schema) -> Option { insert: term, kind: CompletionKind::Field, info: None, + example: None, // With no value yet, go on to the field's values reopen: value.is_empty(), }) @@ -143,7 +147,8 @@ fn field_completions( insert: format!("@{path}:"), label: path, kind: CompletionKind::Field, - info: None, + info: map.info.clone(), + example: map.example.clone(), reopen: true, }); } @@ -157,6 +162,7 @@ fn field_completions( insert, kind: CompletionKind::Field, info: field.info.clone(), + example: field.example.clone(), reopen: true, }); } @@ -191,6 +197,7 @@ fn value_completions( insert: format_value(value), kind: CompletionKind::Value, info: None, + example: None, reopen: false, }) .collect(); @@ -236,14 +243,27 @@ mod tests { kind: FieldKind::Text, map: false, info: None, + example: None, }; Schema::new( vec![ text("method"), text("url"), text("name"), - FieldDef { name: "header".into(), kind: FieldKind::Text, map: true, info: None }, - FieldDef { name: "error".into(), kind: FieldKind::Flag, map: false, info: None }, + FieldDef { + name: "header".into(), + kind: FieldKind::Text, + map: true, + info: None, + example: None, + }, + FieldDef { + name: "error".into(), + kind: FieldKind::Flag, + map: false, + info: None, + example: None, + }, ], FreeText::Fuzzy, ) @@ -369,6 +389,57 @@ mod tests { assert_eq!(at("a|b"), Some(("a".into(), inserts(&["@name:", "@header."])))); } + /// Field names that start with what is typed come first, then those holding it, in the + /// schema's order; nothing matches loosely. + #[test] + fn prefix_matches_first() { + let field = |name: &str| FieldDef { + name: name.into(), + kind: FieldKind::Text, + map: false, + info: None, + example: None, + }; + let s = Schema::new( + vec![ + field("time"), + field("scheme"), + field("method"), + field("host"), + ], + FreeText::Substring, + ); + let labels = |input: &str| { + let c = completions(input, input.len(), &s, &HashMap::new()).unwrap(); + c.items.into_iter().map(|i| i.label).collect::>() + }; + assert_eq!(labels("m"), ["method", "time", "scheme"]); + assert_eq!(labels("@h"), ["host", "scheme", "method"]); + assert!(completions("mt", 2, &s, &HashMap::new()).is_none(), "no fuzzy match"); + } + + /// A field's description and example go with it, and with the keys under a map. + #[test] + fn descriptions_and_examples() { + let s = Schema::new( + vec![FieldDef { + name: "header".into(), + kind: FieldKind::Text, + map: true, + info: Some("A header on either side".into()), + example: Some("@header.content-type:json".into()), + }], + FreeText::Substring, + ); + let values = HashMap::from([("header".to_string(), vec!["accept".to_string()])]); + let c = completions("@he", 3, &s, &values).unwrap(); + assert_eq!(c.items[0].info.as_deref(), Some("A header on either side")); + assert_eq!(c.items[0].example.as_deref(), Some("@header.content-type:json")); + let c = completions("@header.", 8, &s, &values).unwrap(); + assert_eq!(c.items[0].label, "header.accept"); + assert_eq!(c.items[0].example.as_deref(), Some("@header.content-type:json")); + } + #[test] fn cursor_is_clamped() { let c = completions("é", 1, &schema(), &values()); diff --git a/crates/yaak-query/src/condition.rs b/crates/yaak-query/src/condition.rs new file mode 100644 index 000000000..657931217 --- /dev/null +++ b/crates/yaak-query/src/condition.rs @@ -0,0 +1,241 @@ +//! What a field term asks of a field's values, resolved once from the term's value and the +//! field's kind. +//! +//! The evaluator matches with these, and a consumer that compiles queries elsewhere (to SQL, +//! say) translates them rather than deriving the rules again, so the two agree. The helpers +//! below are the value tests the conditions are made of, for a compiler that has to run them +//! where the values are. + +use crate::ast::{CompareOp, FieldValue}; +use crate::lexer::parse_number; +use crate::schema::{FieldKind, Unit, parse_flag, to_base}; + +/// A field term's test, applied to each of the field's values: the term matches when any value +/// passes, except [`Condition::Flag`] `false`, which matches when none is set. +#[derive(Clone, Debug, PartialEq)] +pub enum Condition { + /// The value, as lowercase text, contains this lowercase text, which is never empty. + Contains(String), + /// The whole value, as lowercase text, matches this lowercase pattern, where `*` matches + /// any run of characters. A pattern without `*` must equal the value. + Wildcard(String), + /// The value is there: text that is not empty, any number, or a flag that is set. + Present, + /// The value reads as a number. + IsNumber, + /// The value, read as a number, equals this one, in the field's base unit. + Equals(f64), + /// The value, read as a number, compares so with this one, in the field's base unit. + Compare(CompareOp, f64), + /// The value, read as a number, is in this inclusive range, in the field's base unit. + Range(f64, f64), + /// With `true`, some value is a set flag; with `false`, none is. + Flag(bool), + /// Nothing matches: a value the field cannot take, such as a unit it does not have. + Never, +} + +impl Condition { + /// The condition `value` sets for a field of `kind`. + pub fn new(kind: &FieldKind, value: &FieldValue) -> Self { + match kind { + FieldKind::Text => text_condition(value), + FieldKind::Number { units } => number_condition(value, units), + FieldKind::Flag => flag_condition(value), + } + } + + /// Whether a text value passes. + pub fn matches_text(&self, text: &str) -> bool { + match self { + Condition::Contains(needle) => contains_ignore_case(text, needle), + Condition::Wildcard(pattern) => wildcard_match(pattern, &text.to_lowercase()), + Condition::Present => !text.is_empty(), + Condition::Flag(_) => parse_flag(text) == Some(true), + _ => self.matches_parsed_number(read_number(text)), + } + } + + /// Whether a number value passes. + pub fn matches_number(&self, n: f64) -> bool { + match self { + Condition::Contains(needle) => format_number(n).contains(needle.as_str()), + Condition::Wildcard(pattern) => wildcard_match(pattern, &format_number(n)), + Condition::Present => true, + Condition::Flag(_) => n != 0.0, + _ => self.matches_parsed_number(Some(n)), + } + } + + /// Whether a flag value passes. + pub fn matches_flag(&self, b: bool) -> bool { + let text = if b { "true" } else { "false" }; + match self { + Condition::Contains(needle) => text.contains(needle.as_str()), + Condition::Wildcard(pattern) => wildcard_match(pattern, text), + Condition::Present | Condition::Flag(_) => b, + _ => false, + } + } + + fn matches_parsed_number(&self, n: Option) -> bool { + match self { + Condition::IsNumber => n.is_some(), + Condition::Equals(target) => n == Some(*target), + Condition::Compare(op, target) => n.is_some_and(|n| op.test(n, *target)), + Condition::Range(low, high) => n.is_some_and(|n| n >= *low && n <= *high), + _ => false, + } + } +} + +fn text_condition(value: &FieldValue) -> Condition { + match value { + FieldValue::Text { text, .. } if text.is_empty() => Condition::Never, + FieldValue::Text { text, .. } => Condition::Contains(text.to_lowercase()), + FieldValue::Wildcard { pattern } => Condition::Wildcard(pattern.to_lowercase()), + FieldValue::Exists => Condition::Present, + // A text field holding numbers, such as a status shown as text + FieldValue::Compare { op, number } if number.unit.is_none() => { + Condition::Compare(*op, number.value) + } + FieldValue::Range { low, high } if low.unit.is_none() && high.unit.is_none() => { + Condition::Range(low.value, high.value) + } + FieldValue::Compare { .. } | FieldValue::Range { .. } => Condition::Never, + } +} + +fn number_condition(value: &FieldValue, units: &[Unit]) -> Condition { + match value { + FieldValue::Text { text, .. } if text.is_empty() => Condition::Never, + // `@status:404` is equality; anything else matches the number as text + FieldValue::Text { text, quoted: false } => match parse_number(text) { + Some(number) => to_base(&number, units).map_or(Condition::Never, Condition::Equals), + None => text_condition(value), + }, + FieldValue::Text { quoted: true, .. } | FieldValue::Wildcard { .. } => { + text_condition(value) + } + FieldValue::Exists => Condition::IsNumber, + FieldValue::Compare { op, number } => to_base(number, units) + .map_or(Condition::Never, |target| Condition::Compare(*op, target)), + FieldValue::Range { low, high } => match (to_base(low, units), to_base(high, units)) { + (Some(low), Some(high)) => Condition::Range(low, high), + _ => Condition::Never, + }, + } +} + +fn flag_condition(value: &FieldValue) -> Condition { + match value { + FieldValue::Exists => Condition::Flag(true), + FieldValue::Text { text, .. } => parse_flag(text).map_or(Condition::Never, Condition::Flag), + _ => Condition::Never, + } +} + +/// Whether `hay`, lowercased, contains `needle`, which is lowercase already. Copies nothing +/// when both are ASCII. +pub fn contains_ignore_case(hay: &str, needle: &str) -> bool { + if !(hay.is_ascii() && needle.is_ascii()) { + return hay.to_lowercase().contains(needle); + } + let (hay, needle) = (hay.as_bytes(), needle.as_bytes()); + needle.is_empty() || hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +/// Whether `pattern`, where `*` matches any run of characters, matches the whole of `text`. +/// Case-sensitive: both are lowercase already when matching a query. +pub fn wildcard_match(pattern: &str, text: &str) -> bool { + let mut parts = pattern.split('*'); + let first = parts.next().unwrap_or_default(); + let Some(mut rest) = text.strip_prefix(first) else { + return false; + }; + let parts: Vec<&str> = parts.collect(); + let Some((last, middle)) = parts.split_last() else { + // No `*` at all: the whole text must equal the pattern + return rest.is_empty(); + }; + for part in middle { + match rest.find(part) { + Some(i) => rest = &rest[i + part.len()..], + None => return false, + } + } + rest.len() >= last.len() && rest.ends_with(last) +} + +/// A text value read as a number, as comparisons read it: the whole text, spaces around it +/// aside. +pub fn read_number(text: &str) -> Option { + text.trim().parse().ok() +} + +/// A number as text, as a field's text matching sees it: `200`, not `200.0`. +pub fn format_number(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { n.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::Node; + use crate::parse; + + fn condition(kind: FieldKind, input: &str) -> Condition { + let Some(Node::Field { value, .. }) = parse(input) else { + panic!("not a field") + }; + Condition::new(&kind, &value) + } + + fn ms() -> FieldKind { + FieldKind::Number { + units: vec![ + Unit { name: "ms".into(), factor: 1.0 }, + Unit { name: "s".into(), factor: 1000.0 }, + ], + } + } + + #[test] + fn resolves_text_values() { + assert_eq!(condition(FieldKind::Text, "@a:Foo"), Condition::Contains("foo".into())); + assert_eq!(condition(FieldKind::Text, "@a:"), Condition::Never); + assert_eq!(condition(FieldKind::Text, "@a:F*o"), Condition::Wildcard("f*o".into())); + assert_eq!(condition(FieldKind::Text, "@a:*"), Condition::Present); + assert_eq!(condition(FieldKind::Text, "@a:>5"), Condition::Compare(CompareOp::Gt, 5.0)); + assert_eq!(condition(FieldKind::Text, "@a:>5s"), Condition::Never); + } + + #[test] + fn resolves_number_values_in_base_units() { + assert_eq!(condition(ms(), "@d:1.5s"), Condition::Equals(1500.0)); + assert_eq!(condition(ms(), "@d:1m"), Condition::Never); + assert_eq!(condition(ms(), "@d:<2s"), Condition::Compare(CompareOp::Lt, 2000.0)); + assert_eq!(condition(ms(), "@d:[1s TO 2s]"), Condition::Range(1000.0, 2000.0)); + assert_eq!(condition(ms(), "@d:*"), Condition::IsNumber); + assert_eq!(condition(ms(), "@d:\"15\""), Condition::Contains("15".into())); + assert_eq!(condition(ms(), "@d:abc"), Condition::Contains("abc".into())); + } + + #[test] + fn resolves_flags() { + assert_eq!(condition(FieldKind::Flag, "@f:*"), Condition::Flag(true)); + assert_eq!(condition(FieldKind::Flag, "@f:no"), Condition::Flag(false)); + assert_eq!(condition(FieldKind::Flag, "@f:maybe"), Condition::Never); + } + + #[test] + fn tests_values() { + let c = Condition::Wildcard("40*".into()); + assert!(c.matches_number(404.0)); + assert!(!c.matches_text("504")); + assert!(Condition::Equals(200.0).matches_text(" 200 ")); + assert!(Condition::Contains("é".into()).matches_text("CAFÉ")); + assert!(!Condition::Present.matches_text("")); + assert!(Condition::Flag(true).matches_flag(true)); + } +} diff --git a/crates/yaak-query/src/eval.rs b/crates/yaak-query/src/eval.rs index 85dd998e8..f494678c6 100644 --- a/crates/yaak-query/src/eval.rs +++ b/crates/yaak-query/src/eval.rs @@ -1,8 +1,11 @@ //! Runs a parsed query in memory, against one document or a whole table of them. -use crate::ast::{FieldValue, Node}; +use crate::ast::Node; +use crate::condition::Condition; +#[cfg(test)] +use crate::condition::wildcard_match; use crate::fuzzy::{FuzzyScratch, fuzzy_match_in}; -use crate::schema::{FieldKind, FreeText, Schema, parse_flag, to_base}; +use crate::schema::{FreeText, Schema}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashMap; @@ -208,55 +211,13 @@ enum Val<'a> { } impl Val<'_> { - /// The value as lowercase text. - fn lower(&self) -> Cow<'_, str> { + fn passes(&self, condition: &Condition) -> bool { match self { - Val::Text(text) => Cow::Owned(text.to_lowercase()), - Val::Number(n) => Cow::Owned(format_number(*n)), - Val::Flag(b) => Cow::Borrowed(if *b { "true" } else { "false" }), + Val::Text(text) => condition.matches_text(text), + Val::Number(n) => condition.matches_number(*n), + Val::Flag(b) => condition.matches_flag(*b), } } - - /// Whether the value, lowercased, contains `needle` (lowercase). - fn contains(&self, needle: &str) -> bool { - match self { - Val::Text(text) => contains_ignore_case(text, needle), - _ => self.lower().contains(needle), - } - } - - fn as_number(&self) -> Option { - match self { - Val::Number(n) => Some(*n), - Val::Text(text) => text.trim().parse().ok(), - Val::Flag(_) => None, - } - } - - fn as_flag(&self) -> Option { - match self { - Val::Flag(b) => Some(*b), - Val::Text(text) => parse_flag(text), - Val::Number(n) => Some(*n != 0.0), - } - } - - fn is_present(&self) -> bool { - match self { - Val::Text(text) => !text.is_empty(), - Val::Number(_) => true, - Val::Flag(b) => *b, - } - } -} - -/// Whether `hay`, lowercased, contains `needle` (lowercase), without copying ASCII text. -fn contains_ignore_case(hay: &str, needle: &str) -> bool { - if !(hay.is_ascii() && needle.is_ascii()) { - return hay.to_lowercase().contains(needle); - } - let (hay, needle) = (hay.as_bytes(), needle.as_bytes()); - needle.is_empty() || hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) } /// `text` lowercased into `out`, without a fresh allocation per call. @@ -270,11 +231,6 @@ fn lowercase_into(text: &str, out: &mut String) { } } -/// `200`, not `200.0`, so a number field's text matches what the user sees. -fn format_number(n: f64) -> String { - if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { n.to_string() } -} - /// Where field values come from: a [`Document`], or a row of a [`Table`]. trait FieldSource { /// Whether any value under `path` (lowercase) passes `test`. With `any_key`, `path` is a @@ -351,9 +307,8 @@ enum Plan { path: String, /// A map field with no key: every key's values. any_key: bool, - kind: FieldKind, - /// With its text and wildcard pattern lowercased. - value: FieldValue, + /// Never [`Condition::Never`], which plans as [`Plan::Never`]. + condition: Condition, }, /// A term that can match nothing: an unknown field, an empty word or phrase. Never, @@ -418,14 +373,10 @@ impl Matcher { Plan::Not(node) => !self.eval(node, text, source, fuzzy), Plan::Term(term) if self.fuzzy => fuzzy_match_in(text, term, fuzzy), Plan::Term(term) | Plan::Phrase(term) => text.contains(term.as_str()), - Plan::Field { path, any_key, kind, value } => { - let mut any = - |test: &mut dyn FnMut(Val) -> bool| source.any_value(path, *any_key, test); - match kind { - FieldKind::Text => match_text(&mut any, value), - FieldKind::Number { units } => match_number(&mut any, value, units), - FieldKind::Flag => match_flag(&mut any, value), - } + Plan::Field { path, any_key, condition } => { + let any = source.any_value(path, *any_key, &mut |v| v.passes(condition)); + // `@flag:false`: no value is set + if *condition == Condition::Flag(false) { !any } else { any } } Plan::Never => false, } @@ -458,117 +409,27 @@ fn plan_node(node: &Node, schema: &Schema, uses_text: &mut bool) -> Plan { let Some(field) = schema.resolve(path) else { return Plan::Never; }; - let value = match value { - FieldValue::Text { text, quoted } => { - FieldValue::Text { text: text.to_lowercase(), quoted: *quoted } - } - FieldValue::Wildcard { pattern } => { - FieldValue::Wildcard { pattern: pattern.to_lowercase() } - } - other => other.clone(), - }; + let condition = Condition::new(&field.def.kind, value); + if condition == Condition::Never { + return Plan::Never; + } Plan::Field { path: field.document_path(), any_key: field.def.map && field.key.is_none(), - kind: field.def.kind.clone(), - value, + condition, } } } } -/// Asks whether any of a field's values passes a test. -type AnyValue<'s> = dyn FnMut(&mut dyn FnMut(Val) -> bool) -> bool + 's; - -/// `value`'s text and wildcard pattern are already lowercase (see [`plan_node`]). -fn match_text(any: &mut AnyValue, value: &FieldValue) -> bool { - match value { - FieldValue::Text { text: needle, .. } => { - !needle.is_empty() && any(&mut |v| v.contains(needle)) - } - FieldValue::Wildcard { pattern } => any(&mut |v| wildcard_match(pattern, &v.lower())), - FieldValue::Exists => any(&mut |v| v.is_present()), - // A text field holding numbers, such as a status shown as text - FieldValue::Compare { op, number } => { - number.unit.is_none() - && any(&mut |v| v.as_number().is_some_and(|n| op.test(n, number.value))) - } - FieldValue::Range { low, high } => { - low.unit.is_none() - && high.unit.is_none() - && any(&mut |v| v.as_number().is_some_and(|n| n >= low.value && n <= high.value)) - } - } -} - -fn match_number(any: &mut AnyValue, value: &FieldValue, units: &[crate::Unit]) -> bool { - match value { - FieldValue::Text { text, quoted } => { - if text.is_empty() { - return false; - } - // `@status:404` is equality; anything else matches the number as text - match (!quoted).then(|| crate::lexer::parse_number(text)).flatten() { - Some(number) => to_base(&number, units) - .is_some_and(|target| any(&mut |v| v.as_number() == Some(target))), - None => match_text(any, value), - } - } - FieldValue::Wildcard { .. } => match_text(any, value), - FieldValue::Exists => any(&mut |v| v.as_number().is_some()), - FieldValue::Compare { op, number } => to_base(number, units) - .is_some_and(|target| any(&mut |v| v.as_number().is_some_and(|n| op.test(n, target)))), - FieldValue::Range { low, high } => match (to_base(low, units), to_base(high, units)) { - (Some(low), Some(high)) => { - any(&mut |v| v.as_number().is_some_and(|n| n >= low && n <= high)) - } - _ => false, - }, - } -} - -fn match_flag(any: &mut AnyValue, value: &FieldValue) -> bool { - let mut is_set = || any(&mut |v| v.as_flag() == Some(true)); - match value { - FieldValue::Exists => is_set(), - FieldValue::Text { text, .. } => match parse_flag(text) { - Some(true) => is_set(), - Some(false) => !is_set(), - None => false, - }, - _ => false, - } -} - -/// Whether `pattern`, where `*` matches any run of characters, matches the whole of `text`. -pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool { - let mut parts = pattern.split('*'); - let first = parts.next().unwrap_or_default(); - let Some(mut rest) = text.strip_prefix(first) else { - return false; - }; - let parts: Vec<&str> = parts.collect(); - let Some((last, middle)) = parts.split_last() else { - // No `*` at all: the whole text must equal the pattern - return rest.is_empty(); - }; - for part in middle { - match rest.find(part) { - Some(i) => rest = &rest[i + part.len()..], - None => return false, - } - } - rest.len() >= last.len() && rest.ends_with(last) -} - #[cfg(test)] mod tests { use super::*; - use crate::schema::{FieldDef, Unit}; + use crate::schema::{FieldDef, FieldKind, Unit}; use crate::{format_field, parse}; fn text_field(name: &str) -> FieldDef { - FieldDef { name: name.into(), kind: FieldKind::Text, map: false, info: None } + FieldDef { name: name.into(), kind: FieldKind::Text, map: false, info: None, example: None } } /// The fields and free-text mode of Yaak's sidebar. @@ -594,12 +455,19 @@ mod tests { Schema::new( vec![ text_field("url"), - FieldDef { name: "header".into(), kind: FieldKind::Text, map: true, info: None }, + FieldDef { + name: "header".into(), + kind: FieldKind::Text, + map: true, + info: None, + example: None, + }, FieldDef { name: "status".into(), kind: FieldKind::Number { units: vec![] }, map: false, info: None, + example: None, }, FieldDef { name: "duration".into(), @@ -611,8 +479,15 @@ mod tests { }, map: false, info: None, + example: None, + }, + FieldDef { + name: "error".into(), + kind: FieldKind::Flag, + map: false, + info: None, + example: None, }, - FieldDef { name: "error".into(), kind: FieldKind::Flag, map: false, info: None }, ], FreeText::Substring, ) diff --git a/crates/yaak-query/src/lib.rs b/crates/yaak-query/src/lib.rs index 14cc87e66..e0fc05bd3 100644 --- a/crates/yaak-query/src/lib.rs +++ b/crates/yaak-query/src/lib.rs @@ -9,7 +9,9 @@ //! //! [`parse`] gives the tree, which a consumer may walk to compile it elsewhere (to SQL, say). //! [`evaluate`] runs it in memory against a [`Document`], with a [`Schema`] saying what the -//! fields are. [`tokens`], [`completions`] and [`format_field`] serve the search box. +//! fields are. [`tokens`], [`completions`] and [`format_field`] serve the search box. A compiler +//! resolves each field term to a [`Condition`] and translates that, so it matches as +//! [`evaluate`] does. // The wasm-bindgen exports expand to unsafe glue; nothing else in the crate may use unsafe #![cfg_attr( @@ -20,10 +22,12 @@ pub mod ast; mod complete; +mod condition; mod eval; mod format; pub mod fuzzy; mod lexer; +mod offsets; mod parser; mod schema; mod tokens; @@ -33,8 +37,10 @@ mod wasm; pub use ast::{CompareOp, FieldValue, Node, NumberLiteral, Span}; pub use complete::{Completion, CompletionKind, Completions, completions}; +pub use condition::{Condition, contains_ignore_case, format_number, read_number, wildcard_match}; pub use eval::{DocValue, DocValues, Document, Matcher, Table, evaluate}; pub use format::{format_field, format_value, is_valid_path, quote}; +pub use offsets::Utf16Offsets; pub use parser::parse; pub use schema::{Diagnostic, FieldDef, FieldKind, FreeText, ResolvedField, Schema, Unit}; pub use tokens::{Token, TokenKind, tokens}; diff --git a/crates/yaak-query/src/offsets.rs b/crates/yaak-query/src/offsets.rs new file mode 100644 index 000000000..999c339ac --- /dev/null +++ b/crates/yaak-query/src/offsets.rs @@ -0,0 +1,52 @@ +//! Byte offsets to UTF-16 offsets and back, for spans handed to JavaScript, which counts string +//! positions in UTF-16 code units. + +use crate::ast::Span; + +/// Byte offset to UTF-16 offset, and back, for one input. +pub struct Utf16Offsets { + /// `utf16[i]` is the UTF-16 offset of byte `i`, for every char boundary and the end. + utf16: Vec, +} + +impl Utf16Offsets { + pub fn new(input: &str) -> Self { + let mut utf16 = vec![0; input.len() + 1]; + let mut units = 0u32; + for (i, c) in input.char_indices() { + for b in 0..c.len_utf8() { + utf16[i + b] = units; + } + units += c.len_utf16() as u32; + } + utf16[input.len()] = units; + Self { utf16 } + } + + /// A byte span as a UTF-16 one. + pub fn span(&self, span: Span) -> Span { + Span { start: self.utf16[span.start()], end: self.utf16[span.end()] } + } + + /// The byte offset of UTF-16 offset `pos`, or of the char it falls inside. + pub fn byte(&self, pos: u32) -> usize { + self.utf16.partition_point(|&u| u < pos).min(self.utf16.len() - 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_both_ways() { + let input = "é@a:😀x"; + let offsets = Utf16Offsets::new(input); + // `é` is two bytes and one unit; `😀` is four bytes and two units + assert_eq!(offsets.span(Span::new(2, 5)), Span { start: 1, end: 4 }); + assert_eq!(offsets.span(Span::new(5, 10)), Span { start: 4, end: 7 }); + assert_eq!(offsets.byte(4), 5); + assert_eq!(offsets.byte(6), 9); + assert_eq!(offsets.byte(99), input.len()); + } +} diff --git a/crates/yaak-query/src/schema.rs b/crates/yaak-query/src/schema.rs index 17a1c02fb..b37abc7a5 100644 --- a/crates/yaak-query/src/schema.rs +++ b/crates/yaak-query/src/schema.rs @@ -45,10 +45,14 @@ pub struct FieldDef { #[serde(default)] #[cfg_attr(test, ts(optional, as = "Option"))] pub map: bool, - /// A short note shown with the field in completions. + /// What the field is, in a few words, shown with it in completions: `GET, POST, …`. #[serde(default)] #[cfg_attr(test, ts(optional))] pub info: Option, + /// A term using it, shown with it in completions: `@method:post`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub example: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -207,7 +211,7 @@ mod tests { use crate::parse; fn field(name: &str, kind: FieldKind, map: bool) -> FieldDef { - FieldDef { name: name.to_string(), kind, map, info: None } + FieldDef { name: name.to_string(), kind, map, info: None, example: None } } fn schema() -> Schema { diff --git a/crates/yaak-query/src/wasm.rs b/crates/yaak-query/src/wasm.rs index fbf0089b3..85019be6a 100644 --- a/crates/yaak-query/src/wasm.rs +++ b/crates/yaak-query/src/wasm.rs @@ -3,43 +3,13 @@ //! JavaScript counts string positions in UTF-16 code units, so every span going out is //! converted from bytes, and the cursor coming in is converted to bytes. -use crate::{Document, Matcher, Schema, Span, Table}; +use crate::{Document, Matcher, Schema, Table, Utf16Offsets as Offsets}; use serde::Serialize; use serde::de::DeserializeOwned; use std::collections::HashMap; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; -/// Byte offset to UTF-16 offset, and back, for one input. -struct Offsets { - /// `utf16[i]` is the UTF-16 offset of byte `i`, for every char boundary and the end. - utf16: Vec, -} - -impl Offsets { - fn new(input: &str) -> Self { - let mut utf16 = vec![0; input.len() + 1]; - let mut units = 0u32; - for (i, c) in input.char_indices() { - for b in 0..c.len_utf8() { - utf16[i + b] = units; - } - units += c.len_utf16() as u32; - } - utf16[input.len()] = units; - Self { utf16 } - } - - fn span(&self, span: Span) -> Span { - Span { start: self.utf16[span.start()], end: self.utf16[span.end()] } - } - - /// The byte offset of UTF-16 offset `pos`, or of the char it falls inside. - fn byte(&self, pos: u32) -> usize { - self.utf16.partition_point(|&u| u < pos).min(self.utf16.len() - 1) - } -} - fn to_js(value: &T) -> Result { value .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) diff --git a/package-lock.json b/package-lock.json index 9a34d7914..1238d6bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -272,6 +272,7 @@ "babel-plugin-react-compiler": "^1.0.0", "typescript": "^5.8.3", "vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0", + "vite-plugin-wasm": "^3.5.0", "vite-plus": "^1.4.0" } }, diff --git a/packages/ui/src/components/editor/filter/extension.ts b/packages/ui/src/components/editor/filter/extension.ts index 869a50853..a522e3fe6 100644 --- a/packages/ui/src/components/editor/filter/extension.ts +++ b/packages/ui/src/components/editor/filter/extension.ts @@ -1,8 +1,5 @@ import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"; import { autocompletion, startCompletion } from "@codemirror/autocomplete"; -import { LanguageSupport, LRLanguage, syntaxTree } from "@codemirror/language"; -import type { SyntaxNode } from "@lezer/common"; -import { parser } from "./filter"; export interface FieldDef { name: string; @@ -87,17 +84,7 @@ function fieldCompletionFrom( return null; } -function inPhrase(ctx: CompletionContext): boolean { - // Lezer node names from your grammar: Phrase is the quoted token - let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1); - while (n) { - if (n.name === "Phrase") return true; - n = n.parent; - } - return false; -} - -// While typing an incomplete quote, there's no Phrase token yet. +/** Inside a quote, opened and not yet closed */ function inUnclosedQuote(doc: string, pos: number): boolean { let quotes = 0; for (let i = 0; i < pos; i++) { @@ -200,7 +187,7 @@ export function makeCompletionSource(opts: FilterOptions) { const { state, pos } = ctx; const doc = state.doc.toString(); - if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) { + if (inUnclosedQuote(doc, pos)) { return null; } @@ -250,14 +237,6 @@ export function makeCompletionSource(opts: FilterOptions) { }; } -const language = LRLanguage.define({ - name: "filter", - parser, - languageData: { - autocompletion: {}, - }, -}); - /** * Field and value suggestions, as the sidebar's filter offers them, for a field of another * language: a plain input of `host:` and `app:` entries, say @@ -265,9 +244,3 @@ const language = LRLanguage.define({ export function filterAutocomplete(options: FilterOptions) { return autocompletion({ override: [makeCompletionSource(options)] }); } - -/** Public extension */ -export function filter(options: FilterOptions) { - const source = makeCompletionSource(options); - return new LanguageSupport(language, [autocompletion({ override: [source] })]); -} diff --git a/packages/ui/src/components/editor/filter/filter.grammar b/packages/ui/src/components/editor/filter/filter.grammar deleted file mode 100644 index dcf8b81ec..000000000 --- a/packages/ui/src/components/editor/filter/filter.grammar +++ /dev/null @@ -1,78 +0,0 @@ -@top Query { Expr } - -@skip { space+ } -@tokens { - space { $[ \t\r\n]+ } - - LParen { "(" } - RParen { ")" } - At { "@" } - Colon { ":" } - Not { "-" | "NOT" } - - // Keywords (case-insensitive) - And { "AND" } - Or { "OR" } - - // "quoted phrase" with simple escapes: \" and \\ - Phrase { '"' (!["\\] | "\\" _)* '"' } - - // Bare words run until filter syntax or whitespace. Leading '-' remains unary - // negation, but '-' may appear after the first character. - Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* } - FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* } - - @precedence { Not, And, Or, Word } -} - -@detectDelim - -// Precedence: NOT (highest) > AND > OR (lowest) -// We also allow implicit AND in your parser/evaluator, but for highlighting, -// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq). -Expr { - OrExpr -} - -OrExpr { - AndExpr (Or AndExpr)* -} - -AndExpr { - Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary -} - -Unary { - Not Unary -| Primary -} - -Primary { - Group -| Field -| Phrase -| Term -} - -Group { - LParen Expr RParen -} - -Field { - FieldName Colon FieldValue -} - -FieldName { - At? Word -} - -FieldValue { - Phrase -| FieldValueWord -} - -Term { - Word -} - -@external propSource highlight from "./highlight" diff --git a/packages/ui/src/components/editor/filter/filter.test.ts b/packages/ui/src/components/editor/filter/filter.test.ts deleted file mode 100644 index 6002175f6..000000000 --- a/packages/ui/src/components/editor/filter/filter.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { parser } from "./filter"; - -function getNodeNames(input: string): string[] { - const tree = parser.parse(input); - const nodes: string[] = []; - const cursor = tree.cursor(); - do { - if (cursor.name !== "Query") { - nodes.push(cursor.name); - } - } while (cursor.next()); - return nodes; -} - -describe("filter grammar", () => { - test("parses URL-like field values as one value", () => { - const nodes = getNodeNames("@url:yaak.app/foo-bar"); - - expect(nodes).not.toContain("⚠"); - expect(nodes).toContain("FieldValue"); - expect(nodes).toContain("FieldValueWord"); - }); - - test("parses punctuation-heavy field values as one value", () => { - const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)"); - - expect(nodes).not.toContain("⚠"); - expect(nodes).toContain("FieldValue"); - expect(nodes).toContain("FieldValueWord"); - }); - - test("parses operator-looking field values as one value", () => { - const negativeValueNodes = getNodeNames("@url:-foo"); - const operatorWordNodes = getNodeNames("@url:AND"); - - expect(negativeValueNodes).not.toContain("⚠"); - expect(negativeValueNodes).toContain("FieldValueWord"); - expect(operatorWordNodes).not.toContain("⚠"); - expect(operatorWordNodes).toContain("FieldValueWord"); - }); -}); diff --git a/packages/ui/src/components/editor/filter/filter.ts b/packages/ui/src/components/editor/filter/filter.ts deleted file mode 100644 index 717a6daa4..000000000 --- a/packages/ui/src/components/editor/filter/filter.ts +++ /dev/null @@ -1,22 +0,0 @@ -// This file was generated by lezer-generator. You probably shouldn't edit it. -import {LRParser} from "@lezer/lr" -import {highlight} from "./highlight" -export const parser = LRParser.deserialize({ - version: 14, - states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r", - stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~", - goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog", - nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or", - maxTerm: 27, - nodeProps: [ - ["openedBy", 8,"LParen"], - ["closedBy", 9,"RParen"] - ], - propSources: [highlight], - skippedNodes: [0,22], - repeatNodeCount: 3, - tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p", - tokenizers: [0, 1], - topRules: {"Query":[0,1]}, - tokenPrec: 145 -}) diff --git a/packages/ui/src/components/editor/filter/highlight.ts b/packages/ui/src/components/editor/filter/highlight.ts deleted file mode 100644 index 9c05d7267..000000000 --- a/packages/ui/src/components/editor/filter/highlight.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { styleTags, tags as t } from "@lezer/highlight"; - -export const highlight = styleTags({ - // Boolean operators - And: t.operatorKeyword, - Or: t.operatorKeyword, - Not: t.operatorKeyword, - - // Structural punctuation - LParen: t.paren, - RParen: t.paren, - Colon: t.punctuation, - Minus: t.operator, - - // Literals - Phrase: t.string, // "quoted string" - - // Fields - "FieldName/At": t.attributeName, - "FieldName/Word": t.attributeName, - "FieldValue/FieldValueWord": t.attributeValue, -}); diff --git a/packages/ui/src/components/editor/query/extension.test.ts b/packages/ui/src/components/editor/query/extension.test.ts new file mode 100644 index 000000000..16a4be1d1 --- /dev/null +++ b/packages/ui/src/components/editor/query/extension.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vite-plus/test"; +import { pathsAt } from "./extension"; + +describe("pathsAt", () => { + const at = (text: string) => pathsAt(text, text.length); + + test("is the field whose value is being typed, quoted values with spaces too", () => { + expect(at("@host:api")).toEqual(["host"]); + expect(at('x @app:"Google C')).toEqual(["app"]); + expect(at('@app:"Google Chrome" ')).toEqual([]); + expect(at("@header.content-type:")).toEqual(["header.content-type"]); + }); + + test("is a map's name while its key is typed", () => { + expect(at("@header.x-")).toEqual(["header"]); + expect(at("@res.header.")).toEqual(["res.header"]); + }); + + test("is nothing outside a field", () => { + expect(at("stripe ")).toEqual([]); + expect(at("")).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/editor/query/extension.ts b/packages/ui/src/components/editor/query/extension.ts new file mode 100644 index 000000000..aed6e0f52 --- /dev/null +++ b/packages/ui/src/components/editor/query/extension.ts @@ -0,0 +1,264 @@ +import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { autocompletion, startCompletion } from "@codemirror/autocomplete"; +import { highlightingFor } from "@codemirror/language"; +import type { EditorState, Extension } from "@codemirror/state"; +import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state"; +import type { DecorationSet, ViewUpdate } from "@codemirror/view"; +import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; +import type { Tag } from "@lezer/highlight"; +import { tags as t } from "@lezer/highlight"; +import type { Schema, TokenKind } from "@yaakapp-internal/query"; +import { queryCompletions, queryTokens } from "@yaakapp-internal/query"; + +/** A term the search cannot run, to underline: `from` and `to` count UTF-16 units */ +export interface QueryProblem { + from: number; + to: number; + /** Shown on hover, a few words */ + label: string; + /** Searchable later rather than wrong: marked more quietly */ + quiet?: boolean; +} + +export interface QueryFilterOptions { + schema: Schema; + /** The values seen for each field path, offered after `@field:` */ + values?: Record; + /** + * Values for field paths `values` lacks, read as completion opens: `host` for `@host:`, and a + * map's name, `header`, for the keys after `@header.`. Null for a path with none to offer. + */ + loadValues?: (paths: string[]) => Promise>; + /** + * More completions for a bare word or an empty spot, after the engine's, such as saved + * searches. `typed` is the word, and the completions replace it. + */ + moreCompletions?: (typed: string) => Completion[]; + /** The terms of a query the search cannot run, underlined as typing settles */ + problems?: (text: string) => Promise; +} + +const tokenTags: Record = { + field: t.attributeName, + value: t.attributeValue, + operator: t.operatorKeyword, + quote: t.string, + paren: t.paren, + negation: t.operator, + text: null, +}; + +function buildDecorations(state: EditorState): DecorationSet { + const builder = new RangeSetBuilder(); + for (const token of queryTokens(state.doc.toString())) { + const tag = tokenTags[token.kind]; + const className = tag == null ? null : highlightingFor(state, [tag]); + if (className == null || token.span.start === token.span.end) continue; + builder.add(token.span.start, token.span.end, Decoration.mark({ class: className })); + } + return builder.finish(); +} + +const highlight = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildDecorations(view.state); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.state); + } + } + }, + { decorations: (v) => v.decorations }, +); + +/** + * The field paths whose values the term at `pos` would offer: its field's, a quoted value with + * spaces included, or its map's keys + */ +export function pathsAt(text: string, pos: number): string[] { + const before = text.slice(0, pos); + const value = /@([A-Za-z0-9_.-]+):(?:"(?:[^"\\]|\\.)*|[^\s()"]*)$/.exec(before); + if (value?.[1] != null) return [value[1].toLowerCase()]; + const key = /@([A-Za-z0-9_.-]+)\.[A-Za-z0-9_-]*$/.exec(before); + if (key?.[1] != null) return [key[1].toLowerCase()]; + return []; +} + +/** The bare word being typed at `pos`, or empty at the start of a term */ +function wordAt(text: string, pos: number): { word: string; from: number } | null { + const before = text.slice(0, pos); + const m = /(^|[\s(])([^\s()"@:-][^\s()":]*)?$/.exec(before); + if (m == null) return null; + const word = m[2] ?? ""; + return { word, from: pos - word.length }; +} + +/** The panel beside a completion: what the field is, and a term using it */ +function infoOf(info: string | null, example: string | null): Completion["info"] { + if (example == null) return undefined; + return () => { + const el = document.createElement("div"); + if (info != null) { + const what = document.createElement("div"); + what.textContent = info; + el.append(what); + } + const term = document.createElement("code"); + term.className = "cm-query-example"; + term.textContent = example; + el.append(term); + return el; + }; +} + +function completionSource({ schema, values, loadValues, moreCompletions }: QueryFilterOptions) { + return async (ctx: CompletionContext): Promise => { + const text = ctx.state.doc.toString(); + let known = values ?? {}; + const wanted = pathsAt(text, ctx.pos).filter((p) => known[p] == null); + if (loadValues != null && wanted.length > 0) { + known = { ...known, ...(await loadValues(wanted)) }; + if (ctx.aborted) return null; + } + const result = queryCompletions(text, ctx.pos, schema, known); + + const options: Completion[] = (result?.items ?? []).map((item) => ({ + label: item.label, + // Not a CodeMirror type: it names the kind for `onlyFields` below, and draws no icon + type: item.kind === "field" ? "field" : "value", + detail: item.info ?? undefined, + info: infoOf(item.info, item.example), + apply: (view, _completion, from, to) => { + view.dispatch({ + changes: { from, to, insert: item.insert }, + selection: { anchor: from + item.insert.length }, + }); + if (item.reopen) startCompletion(view); + }, + })); + + let from = result?.span.start ?? ctx.pos; + let to = result?.span.end ?? ctx.pos; + const word = moreCompletions == null ? null : wordAt(text, ctx.pos); + // On an empty spot, only when asked for + if (moreCompletions != null && word != null && (word.word !== "" || ctx.explicit)) { + const more = moreCompletions(word.word); + if (more.length > 0) { + // Both replace the word being typed + if (options.length === 0) [from, to] = [word.from, ctx.pos]; + if (from === word.from) options.push(...more); + } + } + if (options.length === 0) return null; + + // Field names pop up for something typed; on an empty spot, only when asked for + const onlyFields = options.every((o) => o.type === "field"); + if (onlyFields && from === to && !ctx.explicit) return null; + + // The engine has already filtered and ranked the options + return { from, to, options, filter: false }; + }; +} + +const setProblems = StateEffect.define(); + +/** Underlines what the search cannot run, as `problems` last answered for the text */ +const problemField = StateField.define({ + create: () => Decoration.none, + update(decorations, tr) { + let next = decorations.map(tr.changes); + for (const effect of tr.effects) { + if (!effect.is(setProblems)) continue; + const builder = new RangeSetBuilder(); + const length = tr.state.doc.length; + for (const p of [...effect.value].sort((a, b) => a.from - b.from)) { + const from = Math.min(p.from, length); + // An empty value, `@size:`, marks the colon before it + const to = Math.min(Math.max(p.to, p.from + 1), length); + const at = p.to === p.from ? Math.max(0, from - 1) : from; + if (at >= to) continue; + const className = p.quiet ? "cm-query-quiet" : "cm-query-problem"; + builder.add(at, to, Decoration.mark({ class: className, attributes: { title: p.label } })); + } + next = builder.finish(); + } + return next; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +/** How long typing settles before the search's problems are asked for */ +const PROBLEMS_MS = 150; + +function problemChecker(problems: (text: string) => Promise) { + return ViewPlugin.fromClass( + class { + timer: ReturnType | null = null; + + constructor(readonly view: EditorView) { + this.check(); + } + + update(update: ViewUpdate) { + if (!update.docChanged) return; + if (this.timer != null) clearTimeout(this.timer); + this.timer = setTimeout(() => this.check(), PROBLEMS_MS); + } + + check() { + this.timer = null; + const text = this.view.state.doc.toString(); + problems(text) + .then((found) => { + // Answered for text since changed: the next check covers it + if (this.view.state.doc.toString() !== text) return; + this.view.dispatch({ effects: setProblems.of(found) }); + }) + .catch(() => {}); + } + + destroy() { + if (this.timer != null) clearTimeout(this.timer); + } + }, + ); +} + +const problemTheme = EditorView.baseTheme({ + ".cm-query-problem": { + textDecoration: "underline wavy var(--color-danger)", + textUnderlineOffset: "3px", + }, + ".cm-query-quiet": { + textDecoration: "underline dotted var(--color-text-subtlest, currentColor)", + textUnderlineOffset: "3px", + }, +}); + +const completionTheme = EditorView.baseTheme({ + ".cm-query-example": { + display: "block", + marginTop: "0.25rem", + color: "var(--color-text)", + whiteSpace: "pre", + }, +}); + +/** Highlighting and completion for a search box written in Yaak's query language */ +export function queryFilter(options: QueryFilterOptions): Extension { + const extensions: Extension[] = [ + highlight, + // No type icons: the fields and values of a search have no kind the editor's icons name + autocompletion({ override: [completionSource(options)], icons: false }), + completionTheme, + ]; + if (options.problems != null) { + extensions.push(problemField, problemChecker(options.problems), problemTheme); + } + return extensions; +} diff --git a/packages/ui/src/editor.ts b/packages/ui/src/editor.ts index 347d2139b..df2ae1b56 100644 --- a/packages/ui/src/editor.ts +++ b/packages/ui/src/editor.ts @@ -3,10 +3,11 @@ export { CodeEditor } from "./components/editor/CodeEditor"; export type { LanguageSupports } from "./components/editor/extensions"; export type { FieldDef, FilterOptions } from "./components/editor/filter/extension"; export { - filter, filterAutocomplete, makeCompletionSource as filterCompletionSource, } from "./components/editor/filter/extension"; +export type { QueryFilterOptions, QueryProblem } from "./components/editor/query/extension"; +export { queryFilter } from "./components/editor/query/extension"; export { closeBracketsExtensions, getLanguageExtension,