@@ -2,20 +2,43 @@ import type { TargetAnnotationV1 } from '../replay/target-identity.ts';
22import { tryParseSelectorChain } from '../selectors/parse.ts' ;
33
44const VALUE_BEARING_SELECTOR_KEYS = new Set ( [ 'text' , 'label' , 'value' ] ) ;
5+ const STABLE_ROOT_OUTPUT_STRING_KEYS = new Set ( [
6+ 'action' ,
7+ 'backend' ,
8+ 'gesture' ,
9+ 'kind' ,
10+ 'platform' ,
11+ 'ref' ,
12+ 'selector' ,
13+ 'targetKind' ,
14+ ] ) ;
15+ const STRUCTURED_OUTPUT_KEYS = new Set ( [ 'cost' , 'evidence' , 'resolution' , 'settle' ] ) ;
16+ const STRUCTURAL_NESTED_STRING_KEYS = new Set ( [
17+ 'digest' ,
18+ 'foregroundApp' ,
19+ 'id' ,
20+ 'kind' ,
21+ 'ref' ,
22+ 'role' ,
23+ 'source' ,
24+ 'verification' ,
25+ ] ) ;
26+ const IDENTIFIER_CHAR = / ^ [ A - Z a - z 0 - 9 _ ] $ / ;
527
628/**
729 * Parameterize only fields whose contract says they carry the fill value.
8- * Stable identity/provenance strings stay byte-for-byte unchanged; a derived
9- * selector candidate is dropped only when a parsed text/label/value term is
10- * exactly the supplied literal.
30+ * Stable identity/provenance strings stay byte-for-byte unchanged. Untrusted
31+ * backend extras and nested settle output are scrubbed recursively; a derived
32+ * selector candidate is dropped only when a parsed text/label/value term
33+ * semantically contains the supplied literal.
1134 */
1235export function parameterizeRecordedFillPayload <
1336 TPayload extends Record < string , unknown > | undefined ,
1437> ( payload : TPayload , literal : string , placeholder : string ) : TPayload {
1538 if ( ! payload ) return payload ;
1639 const selectorChain = readStringArray ( payload . selectorChain ) ;
1740 return {
18- ...payload ,
41+ ...parameterizeBackendOutput ( payload , literal , placeholder ) ,
1942 ...( typeof payload . text === 'string' ? { text : placeholder } : { } ) ,
2043 ...( payload . refLabel === literal ? { refLabel : placeholder } : { } ) ,
2144 ...( selectorChain
@@ -61,14 +84,122 @@ function selectorCandidateCarriesFillValue(candidate: string, literal: string):
6184 ( term ) =>
6285 VALUE_BEARING_SELECTOR_KEYS . has ( term . key ) &&
6386 typeof term . value === 'string' &&
64- term . value === literal ,
87+ parameterizeSensitiveString ( term . value , literal , '' ) !== term . value ,
6588 ) ,
6689 ) ?? false
6790 ) ;
6891}
6992
93+ function parameterizeBackendOutput (
94+ value : Record < string , unknown > ,
95+ literal : string ,
96+ placeholder : string ,
97+ ) : Record < string , unknown > {
98+ return Object . fromEntries (
99+ Object . entries ( value ) . map ( ( [ key , entry ] ) => [
100+ key ,
101+ parameterizeRootOutputValue ( entry , key , literal , placeholder ) ,
102+ ] ) ,
103+ ) ;
104+ }
105+
106+ function parameterizeRootOutputValue (
107+ value : unknown ,
108+ key : string ,
109+ literal : string ,
110+ placeholder : string ,
111+ ) : unknown {
112+ if ( typeof value === 'string' ) {
113+ if ( STABLE_ROOT_OUTPUT_STRING_KEYS . has ( key ) ) return value ;
114+ if ( key === 'refLabel' ) return value === literal ? placeholder : value ;
115+ }
116+ if ( key === 'selectorChain' && isStringArray ( value ) ) {
117+ return filterSensitiveSelectorCandidates ( value , literal ) ;
118+ }
119+ return parameterizeBackendOutputValue (
120+ value ,
121+ key ,
122+ literal ,
123+ placeholder ,
124+ STRUCTURED_OUTPUT_KEYS . has ( key ) ,
125+ ) ;
126+ }
127+
128+ function parameterizeBackendOutputValue (
129+ value : unknown ,
130+ key : string ,
131+ literal : string ,
132+ placeholder : string ,
133+ preserveStructuralStrings : boolean ,
134+ ) : unknown {
135+ if ( typeof value === 'string' ) {
136+ if ( preserveStructuralStrings && STRUCTURAL_NESTED_STRING_KEYS . has ( key ) ) return value ;
137+ return parameterizeSensitiveString ( value , literal , placeholder ) ;
138+ }
139+ if ( Array . isArray ( value ) ) {
140+ return value . map ( ( entry ) =>
141+ parameterizeBackendOutputValue ( entry , key , literal , placeholder , preserveStructuralStrings ) ,
142+ ) ;
143+ }
144+ if ( ! value || typeof value !== 'object' ) return value ;
145+ return Object . fromEntries (
146+ Object . entries ( value ) . map ( ( [ nestedKey , entry ] ) => [
147+ nestedKey ,
148+ parameterizeBackendOutputValue (
149+ entry ,
150+ nestedKey ,
151+ literal ,
152+ placeholder ,
153+ preserveStructuralStrings ,
154+ ) ,
155+ ] ) ,
156+ ) ;
157+ }
158+
159+ function filterSensitiveSelectorCandidates ( value : string [ ] , literal : string ) : string [ ] {
160+ return value . filter ( ( candidate ) => ! selectorCandidateCarriesFillValue ( candidate , literal ) ) ;
161+ }
162+
163+ function parameterizeSensitiveString ( value : string , literal : string , placeholder : string ) : string {
164+ if ( ! literal . trim ( ) ) return value === literal ? placeholder : value ;
165+ if ( ! isIdentifierLiteral ( literal ) ) {
166+ return value . replaceAll ( literal , placeholder ) ;
167+ }
168+ return parameterizeDelimitedLiteral ( value , literal , placeholder ) ;
169+ }
170+
171+ function isIdentifierLiteral ( literal : string ) : boolean {
172+ return Array . from ( literal ) . every ( ( character ) => IDENTIFIER_CHAR . test ( character ) ) ;
173+ }
174+
175+ function parameterizeDelimitedLiteral ( value : string , literal : string , placeholder : string ) : string {
176+ let cursor = 0 ;
177+ let result = '' ;
178+ while ( cursor < value . length ) {
179+ const matchIndex = value . indexOf ( literal , cursor ) ;
180+ if ( matchIndex === - 1 ) return result + value . slice ( cursor ) ;
181+ result += value . slice ( cursor , matchIndex ) ;
182+ result += isDelimitedMatch ( value , literal , matchIndex ) ? placeholder : literal ;
183+ cursor = matchIndex + literal . length ;
184+ }
185+ return result ;
186+ }
187+
188+ function isDelimitedMatch ( value : string , literal : string , matchIndex : number ) : boolean {
189+ return (
190+ ! isIdentifierCharacter ( value [ matchIndex - 1 ] ) &&
191+ ! isIdentifierCharacter ( value [ matchIndex + literal . length ] )
192+ ) ;
193+ }
194+
195+ function isIdentifierCharacter ( value : string | undefined ) : boolean {
196+ return value !== undefined && IDENTIFIER_CHAR . test ( value ) ;
197+ }
198+
199+ function isStringArray ( value : unknown ) : value is string [ ] {
200+ return Array . isArray ( value ) && value . every ( ( entry ) => typeof entry === 'string' ) ;
201+ }
202+
70203function readStringArray ( value : unknown ) : string [ ] | undefined {
71- return Array . isArray ( value ) && value . every ( ( entry ) => typeof entry === 'string' )
72- ? value
73- : undefined ;
204+ return isStringArray ( value ) ? value : undefined ;
74205}
0 commit comments