Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* Parser and query helpers for dpsapi's log/slog text-handler output (stderr).
|
|
*
|
|
* Line format: `time=<RFC3339 ms UTC> level=<LEVEL> msg=<value> key=value ...` where a value
|
|
* is either bare (no spaces/quotes/=) or Go-quoted.
|
|
*/
|
|
import { goUnquote } from './gostrings';
|
|
|
|
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
|
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
|
|
/** Severity rank: TRACE 0 … ERROR 4. */
|
|
export function levelRank(level: string): number {
|
|
const i = LOG_LEVELS.indexOf(level.toUpperCase() as LogLevel);
|
|
if (i < 0) throw new Error(`unknown log level ${level}`);
|
|
return i;
|
|
}
|
|
|
|
/** Levels that must be emitted when the minimum level is `min`. */
|
|
export function visibleLevels(min: LogLevel): LogLevel[] {
|
|
return LOG_LEVELS.filter((l) => levelRank(l) >= levelRank(min));
|
|
}
|
|
|
|
export interface LogRecord {
|
|
/** The `time` attribute (UTC, millisecond precision). */
|
|
time: string;
|
|
level: string;
|
|
msg: string;
|
|
/** All attributes except time/level/msg; the last value wins for duplicate keys. */
|
|
attrs: Record<string, string>;
|
|
/** Every key/value pair in order, including time/level/msg. */
|
|
pairs: Array<[string, string]>;
|
|
/** The raw line. */
|
|
raw: string;
|
|
/** 0-based line index in the stream. */
|
|
line: number;
|
|
}
|
|
|
|
export type AttrMatcher = string | number | boolean | RegExp | ((value: string | undefined) => boolean);
|
|
|
|
export interface LogQuery {
|
|
level?: string | string[];
|
|
msg?: string | RegExp;
|
|
/** Every listed attribute must match. Use `undefined`-checking functions for absence. */
|
|
attrs?: Record<string, AttrMatcher>;
|
|
}
|
|
|
|
/** Strict format of the slog `time` attribute as emitted by dpsapi (UTC, milliseconds). */
|
|
export const LOG_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
|
|
/** Parses one log line; returns undefined when the line is not a slog record. */
|
|
export function parseLogLine(raw: string, line = 0): LogRecord | undefined {
|
|
if (!raw.startsWith('time=')) return undefined;
|
|
const pairs: Array<[string, string]> = [];
|
|
let i = 0;
|
|
const n = raw.length;
|
|
const readToken = (stopAtEq: boolean): string | undefined => {
|
|
if (raw[i] === '"') {
|
|
// quoted: find closing quote honoring backslash escapes
|
|
let j = i + 1;
|
|
while (j < n && raw[j] !== '"') j += raw[j] === '\\' ? 2 : 1;
|
|
if (j >= n) return undefined;
|
|
const q = raw.slice(i, j + 1);
|
|
i = j + 1;
|
|
try {
|
|
return goUnquote(q);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
let j = i;
|
|
while (j < n && raw[j] !== ' ' && !(stopAtEq && raw[j] === '=')) j++;
|
|
const t = raw.slice(i, j);
|
|
i = j;
|
|
return t;
|
|
};
|
|
while (i < n) {
|
|
while (raw[i] === ' ') i++;
|
|
if (i >= n) break;
|
|
const key = readToken(true);
|
|
if (key === undefined || raw[i] !== '=') return undefined;
|
|
i++;
|
|
const value = readToken(false);
|
|
if (value === undefined) return undefined;
|
|
pairs.push([key, value]);
|
|
}
|
|
if (pairs.length < 3 || pairs[0][0] !== 'time' || pairs[1][0] !== 'level' || pairs[2][0] !== 'msg') return undefined;
|
|
const attrs: Record<string, string> = {};
|
|
for (const [k, v] of pairs.slice(3)) attrs[k] = v;
|
|
return { time: pairs[0][1], level: pairs[1][1], msg: pairs[2][1], attrs, pairs, raw, line };
|
|
}
|
|
|
|
export function matchesQuery(r: LogRecord, q: LogQuery): boolean {
|
|
if (q.level !== undefined) {
|
|
const levels = Array.isArray(q.level) ? q.level : [q.level];
|
|
if (!levels.map((l) => l.toUpperCase()).includes(r.level)) return false;
|
|
}
|
|
if (q.msg !== undefined) {
|
|
if (typeof q.msg === 'string' ? r.msg !== q.msg : !q.msg.test(r.msg)) return false;
|
|
}
|
|
for (const [k, m] of Object.entries(q.attrs ?? {})) {
|
|
const v = Object.prototype.hasOwnProperty.call(r.attrs, k) ? r.attrs[k] : undefined;
|
|
if (typeof m === 'function') {
|
|
if (!m(v)) return false;
|
|
} else if (m instanceof RegExp) {
|
|
if (v === undefined || !m.test(v)) return false;
|
|
} else if (v !== String(m)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export class LogSet {
|
|
readonly records: LogRecord[];
|
|
/** Lines that are not slog records (e.g. the final `dpsapi: ...` error line, usage text). */
|
|
readonly unparsed: Array<{ line: number; text: string }>;
|
|
|
|
constructor(text: string) {
|
|
this.records = [];
|
|
this.unparsed = [];
|
|
const lines = text.split('\n');
|
|
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
lines.forEach((l, idx) => {
|
|
const r = parseLogLine(l, idx);
|
|
if (r) this.records.push(r);
|
|
else this.unparsed.push({ line: idx, text: l });
|
|
});
|
|
}
|
|
|
|
filter(q: LogQuery = {}): LogRecord[] {
|
|
return this.records.filter((r) => matchesQuery(r, q));
|
|
}
|
|
|
|
find(q: LogQuery): LogRecord | undefined {
|
|
return this.records.find((r) => matchesQuery(r, q));
|
|
}
|
|
|
|
count(q: LogQuery = {}): number {
|
|
return this.filter(q).length;
|
|
}
|
|
|
|
has(q: LogQuery): boolean {
|
|
return this.find(q) !== undefined;
|
|
}
|
|
|
|
/** Distinct levels present. */
|
|
levels(): string[] {
|
|
return [...new Set(this.records.map((r) => r.level))];
|
|
}
|
|
|
|
/** Distinct messages present (optionally only at one level). */
|
|
messages(level?: string): string[] {
|
|
return [...new Set(this.records.filter((r) => !level || r.level === level.toUpperCase()).map((r) => r.msg))];
|
|
}
|
|
|
|
/** `this request has been made` records. */
|
|
requests(q: Omit<LogQuery, 'msg'> = {}): LogRecord[] {
|
|
return this.filter({ ...q, msg: 'this request has been made' });
|
|
}
|
|
}
|
|
|
|
export function parseLogs(text: string): LogSet {
|
|
return new LogSet(text);
|
|
}
|