fix: strip a secret under a container shaped unlike its declaration

This commit is contained in:
AlexRV12
2026-09-03 16:30:09 +02:00
parent 7e118ad0e8
commit 995d32f504
2 changed files with 38 additions and 2 deletions
@@ -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', () => {
+21 -2
View File
@@ -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<string, any> = Object.assign(Object.create(null), value)