From 995d32f50434c4cdf9d0c4842538cc4728c38962 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:12:50 +0200 Subject: [PATCH] fix: strip a secret under a container shaped unlike its declaration --- frontend/src/lib/components/job_args.test.ts | 17 +++++++++++++++ frontend/src/lib/components/job_args.ts | 23 ++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/job_args.test.ts b/frontend/src/lib/components/job_args.test.ts index 8dd697db0a..ca0e3e3b8b 100644 --- a/frontend/src/lib/components/job_args.test.ts +++ b/frontend/src/lib/components/job_args.test.ts @@ -219,6 +219,23 @@ describe('secret args at every level the form nests', () => { expect(stripSecretArgs({ creds: [{ token: 'hunter2' }] }, both)).toEqual({ creds: [{}] }) }) + // A container shaped unlike its declaration is kept, so the walk has to reach in + // through the half the declaration does carry — descending on the value's shape alone + // left the secret sitting there for the persisted card and the model to read. + it('strips through a container shaped unlike its declaration', () => { + const declaresArray = { + properties: { + rows: { type: 'array', items: { properties: { token: { password: true } } } } + } + } + expect(stripSecretArgs({ rows: { token: 'hunter2' } }, declaresArray)).toEqual({ rows: {} }) + + const declaresObject = { + properties: { cfg: { type: 'object', properties: { token: { password: true } } } } + } + expect(stripSecretArgs({ cfg: [{ token: 'hunter2' }] }, declaresObject)).toEqual({ cfg: [{}] }) + }) + // The caller binds the result to a form that edits in place, so a schema declaring // nothing must not hand back the object it was given. it('copies even when the schema declares nothing to strip', () => { diff --git a/frontend/src/lib/components/job_args.ts b/frontend/src/lib/components/job_args.ts index 9fd7a0343a..0bbfc9921b 100644 --- a/frontend/src/lib/components/job_args.ts +++ b/frontend/src/lib/components/job_args.ts @@ -1,3 +1,9 @@ +/** + * Preparing a caller's arguments for a run form, and for the readers of one. The filters + * split by what a mistake costs: conforming must not drop what the user meant to send, so + * it stays exact and shallow, while stripping and redacting only blank a field, so they go + * to any depth and err towards visiting too much. + */ import { deepEqual } from 'fast-equals' const isLockedProp = (prop: any) => !!prop?.disabled && 'default' in prop @@ -31,6 +37,13 @@ export function enforceDisabledDefaults( return { args: { ...result }, resetKeys } } +/** How a form says what {@link enforceDisabledDefaults} overwrote, shared by the two that + * run it so the wording cannot drift apart. */ +export const resetKeysToast = (resetKeys: string[]): string => + `Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys + .map((k) => `'${k}'`) + .join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}` + /** * Keys removed from a caller's arguments, split by cause. Kept apart because the two * read as opposites to whoever is told: an undeclared argument is one to stop sending, @@ -138,10 +151,16 @@ function mapLeaves( path: string ): any { if (value == null || typeof value !== 'object') return value + // A container shaped unlike its declaration is kept rather than dropped, since the widget + // is the one that reports it — so the walk has to reach in through whichever half the + // declaration does carry, or a secret under one leaves the form verbatim. if (Array.isArray(value)) - return value.map((item, i) => mapLeaves(item, prop?.items, isLeaf, visit, `${path}[${i}]`)) + return value.map((item, i) => + mapLeaves(item, prop?.items ?? prop, isLeaf, visit, `${path}[${i}]`) + ) const bags = declarationBags(prop) - if (bags.length === 0) return value + if (bags.length === 0) + return prop?.items ? mapLeaves(value, prop.items, isLeaf, visit, path) : value // Null prototype, and keyed off the value rather than the declaration: a key is only // ever rewritten where it already exists, so no branch of a `oneOf` can add one. const result: Record = Object.assign(Object.create(null), value)