diff --git a/src/types/agent-config.ts b/src/types/agent-config.ts new file mode 100644 index 00000000..cb7ff0bb --- /dev/null +++ b/src/types/agent-config.ts @@ -0,0 +1,283 @@ +// Agent remote configuration: wire types for the admin config routes +// (compliance-framework/api internal/api/handler/agent_config.go, API LLD A1/A4.4). +// +// Two casing regimes live side by side here, on purpose: +// * Envelope and report fields are kebab-case on the wire and camelCased by the axios +// interceptor, like every other API response (so they are camelCase below). +// * The config DOCUMENTS (`overlay`, `base`, `effective`, `remote-config`) are opaque and +// returned verbatim in snake_case (R15). They are protected from camelCasing with stop +// paths (useAgentConfigApi STOP_PATHS), so their TS types keep the file's snake_case keys. + +// ---- Opaque config documents (snake_case, verbatim; API A1.2) ---- + +export type AgentConfigMode = 'off' | 'report' | 'apply_safe' | 'apply_all'; + +export interface PluginDoc { + enabled?: boolean | null; + /** R56: File value = key omitted; Auto = null (delete → agent auto-detects); 0 is never sent. */ + protocol_version?: 1 | 2 | null; + schedule?: string | null; + source?: string | null; + policies?: string[] | null; + /** Strings only (R27). */ + config?: Record | null; + labels?: Record | null; + policy_data?: Record | null; + policy_behavior?: Record | null; +} + +/** Normalized `remote_config` file block (R10, R29 defaults). */ +export interface RemoteConfigDoc { + /** Default `report` with API credentials, `off` without (R29). */ + mode?: AgentConfigMode; + /** Default "60s". */ + poll_interval?: string; + /** Default []. */ + trusted_sources?: string[]; + /** Default []. */ + overridable_config_flags?: string[]; + /** Default false. */ + allow_local_sources?: boolean; +} + +/** Base / effective config (redacted, unresolved `${env:}` placeholders). */ +export interface ConfigDoc { + daemon?: boolean; + verbosity?: number; + api?: { url?: string; auth?: { client_id?: string; client_secret?: string } }; + remote_config?: RemoteConfigDoc; + agent_evidence?: { + enabled?: boolean; + emit_on_run_completion?: boolean; + interval?: string; + } | null; + plugins?: Record; +} + +/** JSON Merge Patch (RFC 7396) over ConfigDoc minus the locked keys. `null` deletes. */ +export type OverlayDoc = Omit & + Record; + +export const LOCKED_KEYS = ['api', 'daemon', 'remote_config'] as const; + +/** R25: the API's MaskedValue. Rejected on write. */ +export const REDACTED_MASK = '••••'; + +// ---- Revisions (API agentConfigRevisionResponse) ---- + +export interface AgentConfigRevision { + agentId: string; + /** 0 = never saved. */ + revision: number; + /** Opaque (stop path). Omitted in lists. */ + overlay?: OverlayDoc; + overlaySize: number; + comment: string | null; + /** null for revision 0. */ + createdBy: string | null; + /** null for revision 0. */ + createdAt: string | null; + revertOf: number | null; +} + +export type AgentConfigRevisionSummary = Omit; + +/** 201 → created: true; 200 (semantically unchanged, R14) → created: false. */ +export interface SaveResult { + revision: AgentConfigRevision; + created: boolean; +} + +// ---- Classification and errors (API A1.4, A1.6) ---- + +export type ChangeSafety = 'safe' | 'unsafe' | 'forbidden'; + +/** A classified change; `path` is an RFC 6901 pointer (R5). */ +export interface ConfigChange { + path: string; + safety: ChangeSafety; + reason: string; + value?: string; +} + +/** `path` is an RFC 6901 pointer ("" = root); `code` per R43. */ +export interface FieldError { + path: string; + message: string; + code?: string; +} + +// ---- Instances (API agentInstanceSummary / agentInstanceDetail, R10) ---- + +export type InstanceStatus = + | 'applied' + | 'rejected' + | 'failed' + | 'pending' + | 'not-applicable' + | 'unknown'; + +export type SyncStatus = + | 'in-sync' + | 'out-of-sync' + | 'not-applicable' + | 'unknown'; + +/** R76: one plugin of an instance and the agent library its binary was built with. */ +export interface PluginReport { + /** The plugin's key under `plugins`. */ + name: string; + /** The configured source. */ + source?: string; + /** github.com/compliance-framework/agent version from the binary's build info ('' = unknown). */ + libVersion?: string; +} + +export interface AgentInstanceSummary { + /** Agent-side instance UUID (path param of the detail route). */ + instanceId: string; + hostname: string | null; + agentVersion: string | null; + /** '' = never reported. */ + mode: AgentConfigMode | ''; + firstSeenAt: string; + lastSeenAt: string; + reportedAt: string | null; + /** Server-computed (R14); the UI never recomputes it. */ + stale: boolean; + /** null = file only. */ + appliedRevision: number | null; + attemptedRevision: number | null; + /** pending/unknown are server-derived (R10). */ + status: InstanceStatus; + /** Apply reason vocabulary (constants.ts APPLY_REASON_LABELS). */ + reason: string | null; + error: string | null; + syncStatus: SyncStatus; + effectiveDigest: string | null; + heartbeatConfigRevision: number | null; + /** Heartbeat digest ≠ reported digest. */ + reportStale: boolean; + /** snake_case inside (stop path). Absent when never reported. */ + remoteConfig?: RemoteConfigDoc | null; + unsafe: ConfigChange[]; + /** R10; false = one-shot run (pruned after 24 h, R37). */ + daemon?: boolean | null; + /** R10; the report exceeded 4 MiB and its `base` (the host's file) was dropped. */ + truncated?: boolean; + /** R41: tolerated file-origin problems (e.g. a bad cron → plugin skipped). */ + warnings?: FieldError[]; + /** R76: the reported plugins and their agent library. Empty/absent from older agents. */ + plugins?: PluginReport[] | null; +} + +export interface AgentInstanceDetail extends AgentInstanceSummary { + /** Opaque (stop path). */ + base: ConfigDoc | null; + /** Opaque (stop path). */ + effective: ConfigDoc | null; +} + +export interface InstanceCounts { + total: number; + fresh: number; + stale: number; + inSync: number; + outOfSync: number; + /** API extra (not in the LLD): server-derived pending instances. */ + pending?: number; + rejected: number; + failed: number; + /** API extra (not in the LLD): heartbeat-only instances. */ + unknown?: number; +} + +export interface InstancesMeta { + desiredRevision: number; + /** Over ALL the agent's instances, not only the returned page. */ + counts: InstanceCounts; + /** + * The returned page (1-based), its size, every instance and the page count. The list is + * paginated (limit max 25): an API without these fields returned one page with everything. + */ + page?: number; + limit?: number; + total?: number; + totalPages?: number; +} + +// ---- Preview (API configPreviewResponse) ---- + +/** agentconfig.DiffEntry; unused by the UI (R16), values opaque (stop paths). */ +export interface PreviewDiffEntry { + path: string; + op: 'add' | 'remove' | 'replace' | string; + from?: unknown; + to?: unknown; +} + +export type WillApplyReason = + | 'mode-off' + | 'mode-report' + | 'unsafe-changes' + | 'forbidden-changes' + | 'invalid-config'; + +export interface InstancePreview { + instanceId: string; + hostname: string | null; + mode: AgentConfigMode | ''; + stale: boolean; + /** R48: this instance's base is in the PUT validation set; only its errors block a save. */ + validated?: boolean; + /** Opaque (stop path) = Redact(Merge(base, overlay)). */ + effective: ConfigDoc | null; + /** Optional; the UI does not use it (R16). */ + diffVsCurrent?: PreviewDiffEntry[]; + /** R59: errors the overlay introduces. */ + errors: FieldError[]; + /** R59: problems already in the host file (Merge(base, {})). Never block. */ + warnings?: FieldError[]; + /** Classified against the instance BASE. */ + changes: ConfigChange[]; + willApply: boolean; + willApplyReason?: WillApplyReason | string; +} + +export interface ConfigPreview { + desiredRevision: number; + /** R14: no base available; only overlay-level checks ran. */ + standalone: boolean; + overlayErrors: FieldError[]; + instances: InstancePreview[]; + /** + * Instances the preview left out (the API previews at most 50 instances / 16 MiB). A save + * still validates against them. Absent from older APIs. + */ + omittedInstances?: number; +} + +export interface SaveConfigRequest { + overlay: OverlayDoc; + comment?: string; +} + +/** One validated instance in a 422 body (raw kebab keys). */ +export interface ConfigErrorInstance { + 'instance-id': string; + hostname?: string | null; + errors: FieldError[]; + /** R59: file-origin problems; never block. */ + warnings?: FieldError[]; +} + +/** + * Error bodies are NOT camelCased: the camelcase interceptor only runs on success + * (composables/axios/index.ts). Keys are read raw (R6). + */ +export interface ConfigErrorBody { + body: string; + overlay?: FieldError[]; + instances?: ConfigErrorInstance[]; + 'current-revision'?: number; +} diff --git a/src/types/agents.ts b/src/types/agents.ts index e1c81109..42624acb 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -36,3 +36,7 @@ export interface CreateAgentServiceAccountKeyRequest { expiresAt?: string; neverExpires: boolean; } + +// Agent remote configuration (overlay, instances, preview). Kept in its own module; re-exported +// here so agent types have a single import point. +export * from './agent-config'; diff --git a/src/utils/agent-config/__tests__/cron5.spec.ts b/src/utils/agent-config/__tests__/cron5.spec.ts new file mode 100644 index 00000000..88af7028 --- /dev/null +++ b/src/utils/agent-config/__tests__/cron5.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { describeCron5, validateCron5 } from '../cron5'; + +describe('validateCron5', () => { + it.each([ + '* * * * *', + '*/5 * * * *', + '0 3 * * 1-5', + '15,45 */2 1 JAN-MAR sun', + '0 0 ? * *', + '@hourly', + '@daily', + '@midnight', + '@every 90s', + '@every 1h30m', + 'CRON_TZ=UTC 0 3 * * *', + 'TZ=Europe/Lisbon 0 3 * * *', + 'TZ=US/Eastern 0 3 * * *', + ' */5 * * * * ', + // robfig v3 quirks the agent accepts: empty comma items are skipped, Atoi takes a sign, + // and anything after a leading `*` / `?` in a range is ignored. + '1,,2 * * * *', + ', * * * *', + '+5 * * * *', + '*/+5 * * * *', + '*-5 * * * *', + '?-1-2/3 * * * *', + ])('accepts %s', (expr) => { + expect(validateCron5(expr)).toBeNull(); + }); + + it.each([ + '', + '* * * *', + '* * * * * *', + '60 * * * *', + '* 24 * * *', + '* * 0 * *', + '* * * 13 *', + '* * * * 7', + '*/0 * * * *', + '5-1 * * * *', + 'every night', + '@often', + '@every soon', + '@daily ', + ' @daily', + '@every 5m', + 'TZ=Nowhere/Nope 0 3 * * *', + // Go's LoadLocation is case-sensitive (zoneinfo file names). + 'TZ=utc 0 3 * * *', + 'CRON_TZ=europe/london 0 3 * * *', + '0 0 * constructor *', + '0 0 * * __proto__', + '0 0 * * toString', + '-5 * * * *', + '*/-1 * * * *', + '*/+0 * * * *', + '1-2-3 * * * *', + '*5 * * * *', + '*/99999999999999999999 * * * *', + ])('rejects %s', (expr) => { + expect(validateCron5(expr)).not.toBeNull(); + }); +}); + +describe('describeCron5', () => { + it.each([ + ['* * * * *', 'Every minute'], + ['*/15 * * * *', 'Every 15 minutes'], + ['30 * * * *', 'Every hour at minute 30'], + ['5 3 * * *', 'Every day at 03:05'], + ['0 9 * * 1', 'Every Monday at 09:00'], + ['0 9 * * fri', 'Every Friday at 09:00'], + ['@hourly', 'Every hour'], + ['@daily', 'Every day at 00:00'], + ['@weekly', 'Every Sunday at 00:00'], + ['@monthly', 'On the 1st of every month at 00:00'], + ['@yearly', 'Every year on 1 January at 00:00'], + ['@every 10m', 'Every 10m'], + ['0 9 1 * *', 'Custom schedule'], + ['0 9-17 * * *', 'Custom schedule'], + ])('%s → %s', (expr, text) => { + expect(describeCron5(expr)).toBe(text); + }); +}); diff --git a/src/utils/agent-config/__tests__/glob.spec.ts b/src/utils/agent-config/__tests__/glob.spec.ts new file mode 100644 index 00000000..786ca74f --- /dev/null +++ b/src/utils/agent-config/__tests__/glob.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + configKeyOverridable, + goPathMatch, + pathMatch, + sourceTrusted, +} from '../glob'; + +// Go src/path/match_test.go vectors: [pattern, name, match, badPattern]. +const goVectors: [string, string, boolean, boolean][] = [ + ['abc', 'abc', true, false], + ['*', 'abc', true, false], + ['*c', 'abc', true, false], + ['a*', 'a', true, false], + ['a*', 'abc', true, false], + ['a*', 'ab/c', false, false], + ['a*/b', 'abc/b', true, false], + ['a*/b', 'a/c/b', false, false], + ['a*b*c*d*e*/f', 'axbxcxdxe/f', true, false], + ['a*b*c*d*e*/f', 'axbxcxdxexxx/f', true, false], + ['a*b*c*d*e*/f', 'axbxcxdxe/xxx/f', false, false], + ['a*b*c*d*e*/f', 'axbxcxdxexxx/fff', false, false], + ['a*b?c*x', 'abxbbxdbxebxczzx', true, false], + ['a*b?c*x', 'abxbbxdbxebxczzy', false, false], + ['ab[c]', 'abc', true, false], + ['ab[b-d]', 'abc', true, false], + ['ab[e-g]', 'abc', false, false], + ['ab[^c]', 'abc', false, false], + ['ab[^b-d]', 'abc', false, false], + ['ab[^e-g]', 'abc', true, false], + ['a\\*b', 'a*b', true, false], + ['a\\*b', 'ab', false, false], + ['a?b', 'a☺b', true, false], + ['a[^a]b', 'a☺b', true, false], + ['a???b', 'a☺b', false, false], + ['a[^a][^a][^a]b', 'a☺b', false, false], + ['[a-ζ]*', 'α', true, false], + ['*[a-ζ]', 'A', false, false], + ['a?b', 'a/b', false, false], + ['a*b', 'a/b', false, false], + ['[\\]a]', ']', true, false], + ['[\\-]', '-', true, false], + ['[x\\-]', 'x', true, false], + ['[x\\-]', '-', true, false], + ['[x\\-]', 'z', false, false], + ['[\\-x]', 'x', true, false], + ['[\\-x]', '-', true, false], + ['[\\-x]', 'a', false, false], + ['[]a]', ']', false, true], + ['[-]', '-', false, true], + ['[x-]', 'x', false, true], + ['[x-]', '-', false, true], + ['[x-]', 'z', false, true], + ['[-x]', 'x', false, true], + ['[-x]', '-', false, true], + ['[-x]', 'a', false, true], + ['\\', 'a', false, true], + ['[a-b-c]', 'a', false, true], + ['[', 'a', false, true], + ['[^', 'a', false, true], + ['[^bc', 'a', false, true], + ['a[', 'a', false, true], + ['a[', 'ab', false, true], + ['a[', 'x', false, true], + ['a/b[', 'x', false, true], + ['*x', 'xxx', true, false], +]; + +describe('goPathMatch (Go path.Match vectors)', () => { + it.each(goVectors)( + 'Match(%j, %j) = %s (bad pattern: %s)', + (pattern, name, match, bad) => { + expect(goPathMatch(pattern, name)).toEqual({ + matched: match, + error: bad, + }); + expect(pathMatch(pattern, name)).toBe(match); + }, + ); +}); + +describe('configKeyOverridable', () => { + it.each([ + [['timeout'], 'local-ssh', 'timeout', true], + [['local-ssh:port'], 'local-ssh', 'port', true], + [['local-ssh:port'], 'other', 'port', false], + [['local-*:p*'], 'local-ssh', 'port', true], + [['*'], 'any', 'key', true], + [['a:b:c'], 'a', 'b:c', true], // splits on the FIRST ':' + [['Port'], 'p', 'port', false], // case-sensitive (R28) + [[], 'p', 'port', false], + [['[bad'], 'p', 'port', false], + ])('%j plugin=%s key=%s → %s', (flags, plugin, key, expected) => { + expect(configKeyOverridable(flags, plugin, key)).toBe(expected); + }); +}); + +describe('sourceTrusted', () => { + it('matches with path.Match semantics', () => { + const globs = ['ghcr.io/compliance-framework/*']; + expect(sourceTrusted(globs, 'ghcr.io/compliance-framework/plugin:v1')).toBe( + true, + ); + expect( + sourceTrusted(globs, 'ghcr.io/compliance-framework/sub/plugin:v1'), + ).toBe(false); + expect(sourceTrusted(globs, 'docker.io/evil/plugin')).toBe(false); + }); +}); diff --git a/src/utils/agent-config/cron5.ts b/src/utils/agent-config/cron5.ts new file mode 100644 index 00000000..92e9c33a --- /dev/null +++ b/src/utils/agent-config/cron5.ts @@ -0,0 +1,231 @@ +// Agent plugin schedules: standard 5-field robfig/cron plus descriptors (API +// agentconfig.ParseSchedule). NOT the 6-field workflow validator in utils/cron.ts. + +interface FieldSpec { + name: string; + min: number; + max: number; + names?: Record; +} + +const MONTHS: Record = { + jan: 1, + feb: 2, + mar: 3, + apr: 4, + may: 5, + jun: 6, + jul: 7, + aug: 8, + sep: 9, + oct: 10, + nov: 11, + dec: 12, +}; +const DOWS: Record = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +}; + +const FIELDS: FieldSpec[] = [ + { name: 'minute', min: 0, max: 59 }, + { name: 'hour', min: 0, max: 23 }, + { name: 'day of month', min: 1, max: 31 }, + { name: 'month', min: 1, max: 12, names: MONTHS }, + { name: 'day of week', min: 0, max: 6, names: DOWS }, +]; + +const DESCRIPTORS = [ + '@yearly', + '@annually', + '@monthly', + '@weekly', + '@daily', + '@midnight', + '@hourly', +]; + +/** Go time.ParseDuration syntax (as used by `@every`). */ +export const GO_DURATION_RE = + /^[-+]?(0|((\d+(\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h))+)$/; + +const INT64_MAX = 9223372036854775807n; + +/** Go strconv.Atoi (64-bit int) followed by robfig's non-negative check (mustParseInt). */ +function mustParseInt(raw: string, what: string): number | string { + if (!/^[+-]?\d+$/.test(raw)) return `invalid ${what} "${raw}"`; + const n = BigInt(raw); + if (n > INT64_MAX || n < -INT64_MAX - 1n) return `invalid ${what} "${raw}"`; + if (n < 0n) return `${what} "${raw}" must not be negative`; + return Number(n); +} + +function parseValue(raw: string, spec: FieldSpec): number | string { + const lower = raw.toLowerCase(); + // Own keys only: "constructor" / "__proto__" are not month or weekday names. + if (spec.names && Object.prototype.hasOwnProperty.call(spec.names, lower)) + return spec.names[lower]; + return mustParseInt(raw, `${spec.name} value`); +} + +// robfig getRange: `range[/step]`. A range whose first hyphen part is `*` or `?` is the whole +// field and the rest of it is ignored (`*-5` is `*`); `N/step` means `N-max/step`. +function validateRange(expr: string, spec: FieldSpec): string | null { + const [rangePart, stepPart, ...extra] = expr.split('/'); + if (extra.length > 0) return `too many slashes in ${spec.name} "${expr}"`; + let start: number; + let end: number; + const bounds = rangePart.split('-'); + if (bounds[0] === '*' || bounds[0] === '?') { + start = spec.min; + end = spec.max; + } else { + if (bounds.length > 2) return `too many hyphens in ${spec.name} "${expr}"`; + const lo = parseValue(bounds[0], spec); + if (typeof lo === 'string') return lo; + start = lo; + if (bounds.length === 2) { + const hi = parseValue(bounds[1], spec); + if (typeof hi === 'string') return hi; + end = hi; + } else { + end = stepPart !== undefined ? spec.max : start; + } + } + if (stepPart !== undefined) { + const step = mustParseInt(stepPart, `step of ${spec.name}`); + if (typeof step === 'string') return step; + if (step === 0) { + return `step of ${spec.name} "${expr}" must be a positive number`; + } + } + if (start < spec.min) + return `${spec.name} ${start} is below the minimum ${spec.min}`; + if (end > spec.max) + return `${spec.name} ${end} is above the maximum ${spec.max}`; + if (start > end) return `${spec.name} range "${expr}" starts after it ends`; + return null; +} + +/** Returns an error message, or null when the expression is a valid agent schedule. */ +export function validateCron5(input: string): string | null { + // Mirrors robfig/cron v3 Parse: no trimming before the TZ prefix or a descriptor (so + // "@daily " is rejected as the agent would), fields split like strings.Fields, comma lists + // split like strings.FieldsFunc (empty items are skipped: `1,,2` is `1,2`). + let expr = input; + if (!expr.trim()) return 'Schedule is empty'; + if (expr.startsWith('TZ=') || expr.startsWith('CRON_TZ=')) { + const space = expr.indexOf(' '); + if (space < 0) return 'a time zone prefix must be followed by a schedule'; + const zone = expr.slice(expr.indexOf('=') + 1, space); + if (!validTimeZone(zone)) return `unknown time zone "${zone}"`; + expr = expr.slice(space).trim(); + } + if (expr.startsWith('@')) { + if (DESCRIPTORS.includes(expr)) return null; + if (expr.startsWith('@every ')) { + const d = expr.slice('@every '.length); + return GO_DURATION_RE.test(d) + ? null + : `invalid duration "${d}" in @every`; + } + return `unrecognized descriptor "${expr}"`; + } + const fields = expr.trim().split(/\s+/); + if (fields.length !== 5) { + return `expected 5 fields (minute hour day-of-month month day-of-week), found ${fields.length}`; + } + for (let i = 0; i < 5; i++) { + for (const part of fields[i].split(',')) { + if (part === '') continue; + const err = validateRange(part, FIELDS[i]); + if (err) return err; + } + } + return null; +} + +/** + * Go's time.LoadLocation: "UTC" and "Local", else a zoneinfo file name, which is + * case-sensitive on the agent's (Linux) host. Intl matches names case-insensitively, so a name + * it resolves to a different case only ("utc", "europe/london") is rejected. + */ +function validTimeZone(zone: string): boolean { + if (!zone) return false; + if (zone === 'UTC' || zone === 'Local') return true; + try { + const resolved = new Intl.DateTimeFormat('en', { + timeZone: zone, + }).resolvedOptions().timeZone; + return resolved === zone || resolved.toLowerCase() !== zone.toLowerCase(); + } catch { + return false; + } +} + +const DAY_NAMES = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function isInt(s: string, min: number, max: number): boolean { + return /^\d+$/.test(s) && Number(s) >= min && Number(s) <= max; +} + +/** A short human description of common schedules; anything else is "Custom schedule". */ +export function describeCron5(input: string): string { + const expr = input.trim(); + switch (expr) { + case '@hourly': + return 'Every hour'; + case '@daily': + case '@midnight': + return 'Every day at 00:00'; + case '@weekly': + return 'Every Sunday at 00:00'; + case '@monthly': + return 'On the 1st of every month at 00:00'; + case '@yearly': + case '@annually': + return 'Every year on 1 January at 00:00'; + } + if (expr.startsWith('@every ')) { + const d = expr.slice('@every '.length).trim(); + return GO_DURATION_RE.test(d) ? `Every ${d}` : 'Custom schedule'; + } + const f = expr.split(/\s+/); + if (f.length !== 5) return 'Custom schedule'; + const [m, h, dom, mon, dow] = f; + if (dom !== '*' || mon !== '*') return 'Custom schedule'; + if (m === '*' && h === '*' && dow === '*') return 'Every minute'; + const everyN = /^\*\/(\d+)$/.exec(m); + if (everyN && h === '*' && dow === '*' && Number(everyN[1]) > 0) { + const n = Number(everyN[1]); + return n === 1 ? 'Every minute' : `Every ${n} minutes`; + } + if (isInt(m, 0, 59) && h === '*' && dow === '*') { + return `Every hour at minute ${Number(m)}`; + } + if (isInt(m, 0, 59) && isInt(h, 0, 23)) { + const time = `${pad(Number(h))}:${pad(Number(m))}`; + if (dow === '*') return `Every day at ${time}`; + const d = dow.toLowerCase(); + const idx = isInt(dow, 0, 6) ? Number(dow) : d in DOWS ? DOWS[d] : -1; + if (idx >= 0) return `Every ${DAY_NAMES[idx]} at ${time}`; + } + return 'Custom schedule'; +} diff --git a/src/utils/agent-config/glob.ts b/src/utils/agent-config/glob.ts new file mode 100644 index 00000000..b7394053 --- /dev/null +++ b/src/utils/agent-config/glob.ts @@ -0,0 +1,205 @@ +// Port of Go's path.Match (src/path/match.go), used for lock hints only (API A1.5, R28): +// `*` and `?` do not cross `/`; `[...]`, `[^...]` and `\` escapes are supported; matching +// is case-sensitive. A malformed pattern never matches. The agent/API are authoritative. + +class BadPattern extends Error {} + +type Runes = string[]; + +function scanChunk(pattern: Runes): { + star: boolean; + chunk: Runes; + rest: Runes; +} { + let star = false; + while (pattern.length > 0 && pattern[0] === '*') { + pattern = pattern.slice(1); + star = true; + } + let inrange = false; + let i = 0; + scan: for (i = 0; i < pattern.length; i++) { + switch (pattern[i]) { + case '\\': + // Error check handled in matchChunk: bad pattern. + if (i + 1 < pattern.length) i++; + break; + case '[': + inrange = true; + break; + case ']': + inrange = false; + break; + case '*': + if (!inrange) break scan; + break; + } + } + return { star, chunk: pattern.slice(0, i), rest: pattern.slice(i) }; +} + +function getEsc(chunk: Runes): { r: string; rest: Runes } { + if (chunk.length === 0 || chunk[0] === '-' || chunk[0] === ']') { + throw new BadPattern(); + } + if (chunk[0] === '\\') { + chunk = chunk.slice(1); + if (chunk.length === 0) throw new BadPattern(); + } + const r = chunk[0]; + const rest = chunk.slice(1); + if (rest.length === 0) throw new BadPattern(); + return { r, rest }; +} + +/** Returns the remaining name on a match, or null. Throws BadPattern. */ +function matchChunk(chunk: Runes, s: Runes): Runes | null { + let failed = false; + while (chunk.length > 0) { + if (!failed && s.length === 0) failed = true; + switch (chunk[0]) { + case '[': { + let r = ''; + if (!failed) { + r = s[0]; + s = s.slice(1); + } + chunk = chunk.slice(1); + let negated = false; + if (chunk.length > 0 && chunk[0] === '^') { + negated = true; + chunk = chunk.slice(1); + } + let match = false; + let nrange = 0; + for (;;) { + if (chunk.length > 0 && chunk[0] === ']' && nrange > 0) { + chunk = chunk.slice(1); + break; + } + const lo = getEsc(chunk); + chunk = lo.rest; + let hi = lo.r; + if (chunk[0] === '-') { + const h = getEsc(chunk.slice(1)); + hi = h.r; + chunk = h.rest; + } + const cp = r === '' ? 0 : r.codePointAt(0)!; + if (lo.r.codePointAt(0)! <= cp && cp <= hi.codePointAt(0)!) + match = true; + nrange++; + } + if (match === negated) failed = true; + break; + } + case '?': + if (!failed) { + if (s[0] === '/') failed = true; + s = s.slice(1); + } + chunk = chunk.slice(1); + break; + case '\\': + chunk = chunk.slice(1); + if (chunk.length === 0) throw new BadPattern(); + if (!failed) { + if (chunk[0] !== s[0]) failed = true; + s = s.slice(1); + } + chunk = chunk.slice(1); + break; + default: + if (!failed) { + if (chunk[0] !== s[0]) failed = true; + s = s.slice(1); + } + chunk = chunk.slice(1); + } + } + return failed ? null : s; +} + +/** + * Go path.Match. Returns `{matched, error}` like Go; `error` is true for a malformed + * pattern (Go's ErrBadPattern). + */ +export function goPathMatch( + pattern: string, + name: string, +): { matched: boolean; error: boolean } { + let pat: Runes = Array.from(pattern); + let nm: Runes = Array.from(name); + try { + outer: while (pat.length > 0) { + const scanned = scanChunk(pat); + const { star, chunk } = scanned; + pat = scanned.rest; + if (star && chunk.length === 0) { + // Trailing * matches the rest of the string unless it has a /. + return { matched: !nm.includes('/'), error: false }; + } + // Look for a match at the current position. + const t = matchChunk(chunk, nm); + if (t !== null && (t.length === 0 || pat.length > 0)) { + nm = t; + continue; + } + if (star) { + // Look for a match skipping i+1 runes. Cannot skip /. + for (let i = 0; i < nm.length && nm[i] !== '/'; i++) { + const t2 = matchChunk(chunk, nm.slice(i + 1)); + if (t2 !== null) { + // If we're the last chunk, make sure we exhausted the name. + if (pat.length === 0 && t2.length > 0) continue; + nm = t2; + continue outer; + } + } + } + // Before returning false, check that the rest of the pattern is valid. + while (pat.length > 0) { + const s2 = scanChunk(pat); + pat = s2.rest; + matchChunk(s2.chunk, []); + } + return { matched: false, error: false }; + } + return { matched: nm.length === 0, error: false }; + } catch (e) { + if (e instanceof BadPattern) return { matched: false, error: true }; + throw e; + } +} + +/** path.Match where a malformed pattern means "no match". */ +export function pathMatch(pattern: string, name: string): boolean { + return goPathMatch(pattern, name).matched; +} + +/** + * Mirrors agentconfig.MatchOverridableConfigFlag: an entry containing ':' is + * ":" (split at the FIRST ':'), otherwise "" for any plugin. + */ +export function configKeyOverridable( + flags: readonly string[], + plugin: string, + key: string, +): boolean { + for (const entry of flags) { + const idx = entry.indexOf(':'); + const pluginGlob = idx >= 0 ? entry.slice(0, idx) : '*'; + const keyGlob = idx >= 0 ? entry.slice(idx + 1) : entry; + if (!pathMatch(pluginGlob, plugin)) continue; + if (pathMatch(keyGlob, key)) return true; + } + return false; +} + +/** Mirrors agentconfig.MatchTrustedSource. */ +export function sourceTrusted( + globs: readonly string[], + source: string, +): boolean { + return globs.some((g) => pathMatch(g, source)); +}