From 90dd03af5e0041acdd7b3b0484bc3dad14504856 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 9 Oct 2026 01:41:06 -0700 Subject: [PATCH] Run debugged Sieve scripts under the server's live limits The playground ran every script under its own permissive defaults: a 100,000-instruction CPU limit, loops allowed, generous sizes. A script that breaks the server's limits for its kind passed in the playground and then failed on delivery. Debug now reads the interpreter that runs the script, live: x:SieveUserInterpreter for account scripts (x:SieveUserScript and JMAP SieveScript) and x:SieveSystemInterpreter for system scripts, and passes its limits, notification URIs, protected headers, vacation defaults and expiries to the playground. The extensions follow the server too: account scripts get every capability but vnd.inbuxa.while and the disabled ones; system scripts lose the mailbox actions the server switches off for them. What the server sets in code (the system compiler's limits, the lifted Received limit for account scripts) is repeated in sieveLimits.ts, which names where it comes from. When the settings can't be read, the playground keeps its defaults. No new strings. --- src/components/forms/DynamicForm.tsx | 2 + src/components/forms/FieldWidget.tsx | 41 +++++- src/components/forms/SievepadButton.tsx | 9 +- src/lib/sieveLimits.test.ts | 95 +++++++++++++ src/lib/sieveLimits.ts | 171 ++++++++++++++++++++++++ src/lib/sievepad.test.ts | 5 + src/lib/sievepad.ts | 17 ++- 7 files changed, 327 insertions(+), 13 deletions(-) create mode 100644 src/lib/sieveLimits.test.ts create mode 100644 src/lib/sieveLimits.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 8869c6e..9d5ca69 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -71,6 +71,7 @@ import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { changedFields } from '@/features/settings/changedFromDefault'; import { isSieveScriptField } from '@/lib/sievepad'; +import { interpreterFor } from '@/lib/sieveLimits'; import { reloadActionFor } from '@/lib/settingsApply'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; @@ -899,6 +900,7 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic sieveScriptName={ isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined } + sieveInterpreter={interpreterFor(resolved.obj.objectName)} helpScope={helpScope} explainTarget={ isCreate diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index f1eff85..1665c56 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -34,6 +34,7 @@ import { EventPicker } from '@/features/events/EventPicker'; import type { ExpressionHints } from '@/components/expression/simple'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { SievepadButton } from '@/components/forms/SievepadButton'; +import type { SieveInterpreter } from '@/lib/sieveLimits'; import { bytesToHuman, humanToBytes, @@ -70,6 +71,8 @@ export interface FieldWidgetProps { error?: string; schema: Schema; sieveScriptName?: string; + /** inbuxa: the interpreter that runs that script, for the playground's limits. */ + sieveInterpreter?: SieveInterpreter; /** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */ helpScope?: string; /** inbuxa: the saved record this field belongs to, for "Explain this" (ai-explain EX-17). */ @@ -94,8 +97,19 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); - const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope, explainTarget } = - props; + const { + field, + formField, + value, + onChange, + readOnly, + error, + schema, + sieveScriptName, + sieveInterpreter = 'user', + helpScope, + explainTarget, + } = props; const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined; // INBUXA: the option's default, for its tooltip, and whether it has been changed. const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined; @@ -158,7 +172,15 @@ export function FieldWidget(props: FieldWidgetProps) { /> ); case 'blobId': - return ; + return ( + + ); case 'objectId': return ( {widget} {sieveScriptName !== undefined && ft.type === 'string' && ( - + )} {error &&

{error}

} @@ -1034,9 +1060,10 @@ interface BlobFieldProps { onChange: (value: unknown) => void; readOnly: boolean; sieveScriptName?: string; + sieveInterpreter: SieveInterpreter; } -function BlobField({ value, onChange, readOnly, sieveScriptName }: BlobFieldProps) { +function BlobField({ value, onChange, readOnly, sieveScriptName, sieveInterpreter }: BlobFieldProps) { const { t } = useTranslation(); const blobId = typeof value === 'string' ? value : null; const [content, setContent] = useState(''); @@ -1102,7 +1129,9 @@ function BlobField({ value, onChange, readOnly, sieveScriptName }: BlobFieldProp rows={8} className="font-mono text-xs" /> - {sieveScriptName !== undefined && } + {sieveScriptName !== undefined && ( + + )} {modified && (

{t('field.contentModified', 'Content modified (will be saved as a new blob)')} diff --git a/src/components/forms/SievepadButton.tsx b/src/components/forms/SievepadButton.tsx index d83ca49..8988be6 100644 --- a/src/components/forms/SievepadButton.tsx +++ b/src/components/forms/SievepadButton.tsx @@ -13,19 +13,22 @@ import { Bug } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/hooks/use-toast'; import { openInSievepad } from '@/lib/sievepad'; +import type { SieveInterpreter } from '@/lib/sieveLimits'; interface SievepadButtonProps { scriptName: string; source: string; + interpreter: SieveInterpreter; } // inbuxa: the playground ships with the console, so the script goes nowhere -// else and there is nothing to warn about first. -export function SievepadButton({ scriptName, source }: SievepadButtonProps) { +// else and there is nothing to warn about first. It runs under the limits +// of the interpreter that runs it on the server. +export function SievepadButton({ scriptName, source, interpreter }: SievepadButtonProps) { const { t } = useTranslation(); const open = () => { - openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source).catch(() => { + openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() => { toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' }); }); }; diff --git a/src/lib/sieveLimits.test.ts b/src/lib/sieveLimits.test.ts new file mode 100644 index 0000000..c73566f --- /dev/null +++ b/src/lib/sieveLimits.test.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { interpreterFor, playgroundSettings } from './sieveLimits'; + +const CAPABILITIES = [ + 'fileinto', + 'vacation', + 'duplicate', + 'variables', + 'comparator-elbonia', + 'vnd.inbuxa.while', + 'vnd.inbuxa.expressions', +]; + +describe('interpreterFor', () => { + it('runs system scripts on the system interpreter and the rest on the user one', () => { + expect(interpreterFor('x:SieveSystemScript')).toBe('system'); + expect(interpreterFor('x:SieveUserScript')).toBe('user'); + expect(interpreterFor('SieveScript')).toBe('user'); + }); +}); + +describe('playgroundSettings', () => { + it('gives account scripts the live limits, without while or the disabled extensions', () => { + const settings = playgroundSettings( + 'user', + { + disableCapabilities: { duplicate: true }, + maxCpuCycles: 5000, + maxNestedForEvery: 3, + maxStringLength: 4096, + maxVarNameLength: 32, + maxScriptSize: 102400, + protectedHeaders: { Received: true, 'Auto-Submitted': true }, + allowedNotifyUris: ['mailto'], + defaultSubject: 'Automated reply', + defaultExpiryVacation: 2592000000, + defaultExpiryDuplicate: 604800000, + dkimSignDomain: { else: 'false' }, + }, + CAPABILITIES, + ); + expect(settings).toEqual({ + capabilities: ['fileinto', 'vacation', 'variables', 'comparator-elbonia', 'vnd.inbuxa.expressions'], + cpuLimit: 5000, + maxNestedForeverypart: 3, + maxStringSize: 4096, + maxVariableNameSize: 32, + maxScriptSize: 102400, + maxReceivedHeaders: 4294967295, + protectedHeaders: ['Received', 'Auto-Submitted'], + validNotificationUris: ['mailto'], + vacationDefaultSubject: 'Automated reply', + defaultVacationExpiry: 2592000, + defaultDuplicateExpiry: 604800, + }); + }); + + it('gives system scripts while, no mailbox actions and the fixed compiler limits', () => { + const settings = playgroundSettings( + 'system', + { maxCpuCycles: 1048576, maxVarSize: 52428800, duplicateExpiry: 604800000, noCapabilityCheck: true }, + CAPABILITIES, + ); + expect(settings.capabilities).toEqual([ + 'variables', + 'comparator-elbonia', + 'vnd.inbuxa.while', + 'vnd.inbuxa.expressions', + ]); + expect(settings).toMatchObject({ + noCapabilityCheck: true, + cpuLimit: 1048576, + maxVariableSize: 52428800, + defaultDuplicateExpiry: 604800, + maxStringSize: 52428800, + maxHeaderSize: 10240, + maxIncludes: 10, + validNotificationUris: ['mailto'], + }); + }); + + it('leaves out what the server did not send', () => { + expect(playgroundSettings('user', {}, CAPABILITIES)).toEqual({ + capabilities: ['fileinto', 'vacation', 'duplicate', 'variables', 'comparator-elbonia', 'vnd.inbuxa.expressions'], + maxReceivedHeaders: 4294967295, + }); + }); +}); diff --git a/src/lib/sieveLimits.ts b/src/lib/sieveLimits.ts new file mode 100644 index 0000000..f4f5034 --- /dev/null +++ b/src/lib/sieveLimits.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: the Sieve playground runs a script under the limits and + * extensions the server gives that kind of script, read live from + * x:SieveUserInterpreter or x:SieveSystemInterpreter, so a script that + * breaks a limit fails in the playground as it would on delivery. The + * mapping follows inbuxa-server's crates/common/src/config/mailstore/ + * scripts.rs; what it sets in code rather than from the settings is + * repeated here. + */ + +import { getAccountId, jmapGet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; + +export type SieveInterpreter = 'user' | 'system'; + +/** Which interpreter runs a script field's object. */ +export function interpreterFor(objectName: string): SieveInterpreter { + return objectName === 'x:SieveSystemScript' ? 'system' : 'user'; +} + +const OBJECT: Record = { + user: 'x:SieveUserInterpreter', + system: 'x:SieveSystemInterpreter', +}; + +// sieve-rs's usize::MAX as the playground clamps it: the server lifts the +// interpreter's own Received limit for account scripts and checks it while +// delivering instead. +const UNLIMITED = 4294967295; + +// What the server switches off for system scripts, which run before +// delivery and have no mailbox to act on. +const SYSTEM_DISABLED = [ + 'fileinto', + 'vacation', + 'vacation-seconds', + 'fcc', + 'mailbox', + 'mailboxid', + 'mboxmetadata', + 'servermetadata', + 'imapsieve', + 'duplicate', +]; + +type Settings = Record; + +/** A JMAP set (an object keyed by value) or a plain list, as strings. */ +function members(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string'); + if (value && typeof value === 'object') { + return Object.entries(value as Record) + .filter(([, present]) => present) + .map(([key]) => key); + } + return []; +} + +/** Copies the numbers that are set, under the playground's names. */ +function numbers(from: Settings, names: Record, to: Settings) { + for (const [server, playground] of Object.entries(names)) { + const value = from[server]; + if (typeof value === 'number' && Number.isFinite(value)) to[playground] = value; + } +} + +function seconds(ms: unknown): number | undefined { + return typeof ms === 'number' && Number.isFinite(ms) ? Math.round(ms / 1000) : undefined; +} + +/** + * The playground settings for a script run by `interpreter`, from that + * interpreter's live settings and every Sieve capability the server knows. + */ +export function playgroundSettings(interpreter: SieveInterpreter, live: Settings, capabilities: string[]): Settings { + const settings: Settings = {}; + if (interpreter === 'user') { + const disabled = new Set([...members(live.disableCapabilities), 'vnd.inbuxa.while']); + settings.capabilities = capabilities.filter((name) => !disabled.has(name)); + numbers( + live, + { + maxCpuCycles: 'cpuLimit', + maxHeaderSize: 'maxHeaderSize', + maxIncludes: 'maxIncludes', + maxLocalVars: 'maxLocalVariables', + maxMatchVars: 'maxMatchVariables', + maxNestedBlocks: 'maxNestedBlocks', + maxNestedForEvery: 'maxNestedForeverypart', + maxNestedIncludes: 'maxNestedIncludes', + maxNestedTests: 'maxNestedTests', + maxOutMessages: 'maxOutMessages', + maxRedirects: 'maxRedirects', + maxScriptSize: 'maxScriptSize', + maxStringLength: 'maxStringSize', + maxVarNameLength: 'maxVariableNameSize', + maxVarSize: 'maxVariableSize', + }, + settings, + ); + settings.maxReceivedHeaders = UNLIMITED; + if (live.protectedHeaders !== undefined) settings.protectedHeaders = members(live.protectedHeaders); + if (live.allowedNotifyUris !== undefined) settings.validNotificationUris = members(live.allowedNotifyUris); + if (typeof live.defaultSubject === 'string') settings.vacationDefaultSubject = live.defaultSubject; + if (typeof live.defaultSubjectPrefix === 'string') settings.vacationSubjectPrefix = live.defaultSubjectPrefix; + const vacation = seconds(live.defaultExpiryVacation); + if (vacation !== undefined) settings.defaultVacationExpiry = vacation; + const duplicate = seconds(live.defaultExpiryDuplicate); + if (duplicate !== undefined) settings.defaultDuplicateExpiry = duplicate; + return settings; + } + + const disabled = new Set(SYSTEM_DISABLED); + settings.capabilities = capabilities.filter((name) => !disabled.has(name)); + if (typeof live.noCapabilityCheck === 'boolean') settings.noCapabilityCheck = live.noCapabilityCheck; + // The system compiler's fixed limits, and sieve-rs's defaults for the + // ones the server leaves alone. + Object.assign(settings, { + maxScriptSize: 1024 * 1024, + maxStringSize: 52428800, + maxVariableNameSize: 100, + maxNestedBlocks: 50, + maxNestedTests: 50, + maxNestedForeverypart: 10, + maxMatchVariables: 30, + maxLocalVariables: 8192, + maxHeaderSize: 10240, + maxIncludes: 10, + validNotificationUris: ['mailto'], + protectedHeaders: ['Original-Subject', 'Original-From'], + }); + numbers( + live, + { + maxCpuCycles: 'cpuLimit', + maxNestedIncludes: 'maxNestedIncludes', + maxOutMessages: 'maxOutMessages', + maxReceivedHeaders: 'maxReceivedHeaders', + maxRedirects: 'maxRedirects', + maxVarSize: 'maxVariableSize', + }, + settings, + ); + const duplicate = seconds(live.duplicateExpiry); + if (duplicate !== undefined) settings.defaultDuplicateExpiry = duplicate; + return settings; +} + +/** + * Reads the interpreter's settings from the server. Empty, so the + * playground keeps its own defaults, when they can't be read (the + * administrator may not be allowed to see them). + */ +export async function fetchPlaygroundSettings(interpreter: SieveInterpreter): Promise { + const capabilities = (useSchemaStore.getState().schema?.enums?.SieveCapability ?? []).map((variant) => variant.name); + if (capabilities.length === 0) return {}; + try { + const object = OBJECT[interpreter]; + const [response] = await jmapGet(object, getAccountId(object), ['singleton']); + const live = ((response?.[1] as { list?: Settings[] } | undefined)?.list ?? [])[0]; + return live ? playgroundSettings(interpreter, live, capabilities) : {}; + } catch { + return {}; + } +} diff --git a/src/lib/sievepad.test.ts b/src/lib/sievepad.test.ts index b2195a9..e7cbf7c 100644 --- a/src/lib/sievepad.test.ts +++ b/src/lib/sievepad.test.ts @@ -41,6 +41,11 @@ describe('sievepadLink', () => { }); }); + it('carries the interpreter settings it is given', async () => { + const link = await sievepadLink('Filters', 'keep;', { cpuLimit: 5000, capabilities: ['fileinto'] }); + expect((decode(link) as { settings: unknown }).settings).toEqual({ cpuLimit: 5000, capabilities: ['fileinto'] }); + }); + it('truncates long workspace names', async () => { const link = await sievepadLink('x'.repeat(200), 'keep;'); expect((decode(link) as { name: string }).name).toHaveLength(80); diff --git a/src/lib/sievepad.ts b/src/lib/sievepad.ts index b829cd9..35f2f84 100644 --- a/src/lib/sievepad.ts +++ b/src/lib/sievepad.ts @@ -7,6 +7,8 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ +import { fetchPlaygroundSettings, type SieveInterpreter } from './sieveLimits'; + // inbuxa: the Sieve playground ships with the console (sieve-playground.ts) // and runs the server's own interpreter, so the script never leaves the // console's origin. @@ -37,25 +39,32 @@ function toBase64Url(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } -export async function sievepadLink(name: string, source: string, base = playgroundUrl()): Promise { +export async function sievepadLink( + name: string, + source: string, + settings: Record = {}, + base = playgroundUrl(), +): Promise { const json = JSON.stringify({ v: SIEVEPAD_FORMAT_VERSION, name: name.slice(0, SIEVEPAD_MAX_NAME_LENGTH), scripts: [{ name: SIEVEPAD_MAIN_SCRIPT, source: source.replace(/\r\n/g, '\n') }], messages: [], - settings: {}, + settings, }); const stream = new Blob([new TextEncoder().encode(json)]).stream().pipeThrough(new CompressionStream('deflate-raw')); const packed = new Uint8Array(await new Response(stream).arrayBuffer()); return `${base}#w=${toBase64Url(packed)}`; } -export async function openInSievepad(name: string, source: string): Promise { +// inbuxa: the script runs under the limits of the interpreter that runs it +// on the server (sieveLimits.ts). +export async function openInSievepad(name: string, source: string, interpreter: SieveInterpreter): Promise { const tab = window.open('about:blank', '_blank'); if (tab) tab.opener = null; let link: string; try { - link = await sievepadLink(name, source); + link = await sievepadLink(name, source, await fetchPlaygroundSettings(interpreter)); } catch (err) { tab?.close(); throw err; -- 2.54.0