@@ -100,6 +100,107 @@ describe('redactStatementFromMessage', () => {
100100 } ) ;
101101} ) ;
102102
103+ // #8823 — the tail is kept because it names IDENTIFIERS, and on MySQL's
104+ // duplicate-entry family it does not: `ER_DUP_ENTRY` prints the conflicting
105+ // VALUE in the diagnostic itself. These cases pin both halves of the remedy —
106+ // the value goes, the index name stays — because a fix that blanked the tail
107+ // would trade away exactly the debuggability #8682 paid for.
108+ //
109+ // ⛔ Not a demonstrated deployment leak: no live MySQL server was measured.
110+ // The inputs are this repo's own recorded mysql2 phrasings
111+ // (`packages/types/src/unique-violation.ts`) in knex's documented shape.
112+ describe ( '#8823 — a caller value inlined in the diagnostic itself' , ( ) => {
113+ const EMAIL = 'acme@example.com' ;
114+ const BOUND_DUP_ENTRY =
115+ 'insert into `crm_account` (`email`, `name`) values '
116+ + `('${ EMAIL } ', 'Acme')`
117+ + ` - Duplicate entry '${ EMAIL } ' for key 'crm_account.email'` ;
118+
119+ it ( 'drops the conflicting value and keeps the index the operator needs' , ( ) => {
120+ const out = redactStatementFromMessage ( BOUND_DUP_ENTRY ) ;
121+
122+ expect ( out ) . not . toContain ( EMAIL ) ;
123+ // The index name is the answer to "which constraint?" and survives whole.
124+ expect ( out ) . toContain ( "for key 'crm_account.email'" ) ;
125+ // Still legibly MySQL's own diagnostic, not a blanked tail.
126+ expect ( out ) . toContain ( 'Duplicate entry' ) ;
127+ expect ( out ) . toBe (
128+ "Duplicate entry [value redacted] for key 'crm_account.email' [statement and bound values redacted]" ,
129+ ) ;
130+ } ) ;
131+
132+ it ( 'redacts a BARE diagnostic too — the shape that reaches us without a statement' , ( ) => {
133+ // Before #9030 the shared leak predicate did not recognise this phrasing,
134+ // so a bare `Duplicate entry …` was turned away at the door and kept its
135+ // value. That limb landed for a different reason; the two compose here.
136+ const out = redactStatementFromMessage ( `Duplicate entry '${ EMAIL } ' for key 'crm_account.email'` ) ;
137+
138+ expect ( out ) . not . toContain ( EMAIL ) ;
139+ expect ( out ) . toBe ( "Duplicate entry [value redacted] for key 'crm_account.email'" ) ;
140+ // No statement was present, so the entry must not claim one was removed.
141+ expect ( out ) . not . toContain ( '[statement and bound values redacted]' ) ;
142+ } ) ;
143+
144+ it ( 'leaves no fragment when the value itself contained " - "' , ( ) => {
145+ // The statement cut takes the LAST separator, which lands INSIDE a value
146+ // spelled like this — measured on the shipped function, it logged
147+ // `Q3 plan' for key 't.label'`. The words are not reconstructed: an anchor
148+ // is evidence about the value, not licence to assert the template.
149+ const out = redactStatementFromMessage (
150+ "insert into `t` (`label`) values ('2026 - Q3 plan')"
151+ + " - Duplicate entry '2026 - Q3 plan' for key 't.label'" ,
152+ ) ;
153+
154+ expect ( out ) . not . toContain ( 'Q3 plan' ) ;
155+ expect ( out ) . not . toContain ( '2026' ) ;
156+ expect ( out ) . toContain ( "for key 't.label'" ) ;
157+ } ) ;
158+
159+ it . each ( [
160+ [ "an unescaped quote in the value" , "Duplicate entry 'O'Brien' for key 't.name'" , 'Brien' , "for key 't.name'" ] ,
161+ [ 'a composite key value' , "Duplicate entry 'acme-x' for key 't.idx_a_b'" , 'acme-x' , "for key 't.idx_a_b'" ] ,
162+ [ 'the PRIMARY key' , "Duplicate entry 'r1' for key 'PRIMARY'" , "'r1'" , "for key 'PRIMARY'" ] ,
163+ // Ambiguous: the value may itself contain the anchor. Resolving to the LAST
164+ // anchor discards more, which is the only direction that cannot leak.
165+ [ 'a value that mimics the anchor' , "Duplicate entry 'a' for key 'b' for key 't.n'" , "for key 'b'" , "for key 't.n'" ] ,
166+ ] ) ( '%s' , ( _shape , diagnostic , gone , kept ) => {
167+ const out = redactStatementFromMessage ( `insert into \`t\` (\`c\`) values ('v') - ${ diagnostic } ` ) ;
168+
169+ expect ( out ) . not . toContain ( gone ) ;
170+ expect ( out ) . toContain ( kept ) ;
171+ expect ( out ) . toContain ( '[value redacted]' ) ;
172+ } ) ;
173+
174+ it ( 'leaves every IDENTIFIER-bearing tail exactly as it was' , ( ) => {
175+ // The other three dialect shapes the card measured, plus the MySQL family
176+ // that names a column rather than a value. Redacting these would be the
177+ // regression #8682's triage warned about, not a fix.
178+ for ( const [ statement , diagnostic ] of [
179+ [ "insert into `t` (`c`) values ('v')" , "Unknown column 'zzz' in 'field list'" ] ,
180+ [ "insert into `t` (`c`) values ('v')" , 'UNIQUE constraint failed: crm_account.email' ] ,
181+ [ 'insert into "t" ("c") values (\'v\')' , 'duplicate key value violates unique constraint "crm_account_email_key"' ] ,
182+ [ "insert into `t` (`c`) values ('v')" , 'NOT NULL constraint failed: sys_team.organization_id' ] ,
183+ ] ) {
184+ const out = redactStatementFromMessage ( `${ statement } - ${ diagnostic } ` ) ;
185+
186+ expect ( out ) . toBe ( `${ diagnostic } [statement and bound values redacted]` ) ;
187+ expect ( out ) . not . toContain ( '[value redacted]' ) ;
188+ }
189+ } ) ;
190+
191+ it ( 'does not reach into a bound value that merely looks like the template' , ( ) => {
192+ // The templates are read only AFTER the cut, where nothing but the
193+ // database's own words is left — so a caller storing this text in a column
194+ // cannot steer what survives.
195+ const out = redactStatementFromMessage (
196+ "insert into `t` (`note`) values ('Duplicate entry \\'x\\' for key \\'k\\'')"
197+ + ' - table t has no column named note' ,
198+ ) ;
199+
200+ expect ( out ) . toBe ( 'table t has no column named note [statement and bound values redacted]' ) ;
201+ } ) ;
202+ } ) ;
203+
103204describe ( 'redactBoundStatement' , ( ) => {
104205 it ( 'redacts `stack` too — the statement opened it a second time' , ( ) => {
105206 const original = new Error ( BOUND_INSERT ) ;
@@ -167,7 +268,18 @@ describe('#8682 half B — the write-path loggers', () => {
167268 return logger ;
168269 }
169270
170- async function insertAgainstADriftedColumn ( ) {
271+ /**
272+ * The one driver double in this file. #8823 needed a MySQL-shaped fault and
273+ * the shape is a PARAMETER rather than a second double — one fake engine per
274+ * file keeps the contract the double implements reviewable in one place.
275+ */
276+ const DRIFTED_COLUMN = {
277+ name : 'SqliteError' ,
278+ code : 'SQLITE_ERROR' ,
279+ diagnostic : ( object : string ) => `table ${ object } has no column named secret_note` ,
280+ } ;
281+
282+ async function insertAgainstADriftedColumn ( fault = DRIFTED_COLUMN ) {
171283 const logger = makeCapturingLogger ( ) ;
172284 const engine = new ObjectQL ( { logger } ) ;
173285 const driver : any = {
@@ -178,10 +290,11 @@ describe('#8682 half B — the write-path loggers', () => {
178290 async create ( object : string , data : Record < string , unknown > ) {
179291 const cols = Object . keys ( data ) . sort ( ) ;
180292 const stmt = `insert into \`${ object } \` (${ cols . map ( ( c ) => `\`${ c } \`` ) . join ( ', ' ) } ) values (${ cols . map ( ( c ) => `'${ String ( data [ c ] ) } '` ) . join ( ', ' ) } ) returning *` ;
181- const e : any = new Error ( `${ stmt } - table ${ object } has no column named secret_note` ) ;
182- e . name = 'SqliteError' ;
183- e . code = 'SQLITE_ERROR' ;
184- e . stack = `SqliteError: ${ stmt } - table ${ object } has no column named secret_note\n at Database.prepare (/x/better-sqlite3.js:1:1)` ;
293+ const text = `${ stmt } - ${ fault . diagnostic ( object ) } ` ;
294+ const e : any = new Error ( text ) ;
295+ e . name = fault . name ;
296+ e . code = fault . code ;
297+ e . stack = `${ fault . name } : ${ text } \n at Database.prepare (/x/better-sqlite3.js:1:1)` ;
185298 throw e ;
186299 } ,
187300 async update ( ) { return { } ; } , async updateMany ( ) { return 0 ; } ,
@@ -248,4 +361,48 @@ describe('#8682 half B — the write-path loggers', () => {
248361 expect ( String ( thrown ?. message ) ) . toContain ( 'insert into' ) ;
249362 expect ( String ( thrown ?. message ) ) . toContain ( SECRET ) ;
250363 } ) ;
364+
365+ /**
366+ * [#8823] The same write path, with the fault MySQL raises instead — where
367+ * the caller's value is in the DIAGNOSTIC and not only in the statement, so
368+ * the statement cut alone never reached it.
369+ */
370+ const MYSQL_DUPLICATE_ENTRY = {
371+ name : 'Error' ,
372+ code : 'ER_DUP_ENTRY' ,
373+ diagnostic : ( object : string ) => `Duplicate entry '${ SECRET } ' for key '${ object } .secret_note'` ,
374+ } ;
375+
376+ it ( 'MySQL duplicate entry — the entry survives and still names the index' , async ( ) => {
377+ const { line } = await insertAgainstADriftedColumn ( MYSQL_DUPLICATE_ENTRY ) ;
378+
379+ expect ( line ) . toBeDefined ( ) ;
380+ expect ( line ! . level ) . toBe ( 'error' ) ;
381+ expect ( line ! . meta ) . toEqual ( { object : 'crm_account' } ) ;
382+ // The operator's answer to "which constraint?" is kept whole.
383+ expect ( String ( line ! . err ?. message ) ) . toContain ( "for key 'crm_account.secret_note'" ) ;
384+ expect ( String ( line ! . err ?. stack ) ) . toContain ( 'at Database.prepare' ) ;
385+ } ) ;
386+
387+ it ( 'MySQL duplicate entry — neither `message` nor `stack` carries the value' , async ( ) => {
388+ const { line } = await insertAgainstADriftedColumn ( MYSQL_DUPLICATE_ENTRY ) ;
389+
390+ for ( const field of [ String ( line ! . err ?. message ) , String ( line ! . err ?. stack ) ] ) {
391+ expect ( field ) . not . toContain ( SECRET ) ;
392+ expect ( field ) . not . toContain ( DESCRIPTION ) ;
393+ expect ( field ) . not . toContain ( 'insert into' ) ;
394+ }
395+ } ) ;
396+
397+ it ( 'MySQL duplicate entry — the RETHROWN error is still untouched' , async ( ) => {
398+ // Same boundary as above: the log narrows, the caller's answer does not
399+ // move. `isUniqueViolationError` and `uniqueViolationColumn` read this
400+ // message downstream and must keep seeing the driver's own text.
401+ const { thrown } = await insertAgainstADriftedColumn ( MYSQL_DUPLICATE_ENTRY ) ;
402+
403+ expect ( String ( thrown ?. message ) ) . toContain ( 'insert into' ) ;
404+ expect ( String ( thrown ?. message ) ) . toContain ( SECRET ) ;
405+ expect ( String ( thrown ?. message ) ) . toContain ( 'Duplicate entry' ) ;
406+ expect ( ( thrown as any ) ?. code ) . toBe ( 'ER_DUP_ENTRY' ) ;
407+ } ) ;
251408} ) ;
0 commit comments