@@ -349,6 +349,90 @@ public void test_prepare_tail_string()
349349 }
350350 }
351351
352+ // Regression test for the ArgumentOutOfRangeException variant of the
353+ // long-standing prepare-race bug class (see issues #108, #321, #430,
354+ // #479, #588). The provider's span overload of sqlite3_prepare_v2
355+ // computes `(int)(p_tail - p_sql)` and uses it as a Slice start.
356+ //
357+ // On SQLITE_MISUSE paths (unsafe db handle, null zSql), native returns
358+ // without writing *pzTail, leaving the caller's `out byte* p_tail`
359+ // local at its .locals-init default of 0. For a non-empty SQL input,
360+ // p_sql is a real non-null pointer, so `p_tail - p_sql` is a 64-bit
361+ // signed difference that truncates to an int whose sign depends on
362+ // bit 31 of the low 32 bits of p_sql. When bit 31 is clear (the sql
363+ // buffer sits at a typical high managed-heap address on x64), the
364+ // cast produces a large negative int, and `sql.Slice(negative, ...)`
365+ // throws ArgumentOutOfRangeException instead of cleanly returning
366+ // the error rc to the caller.
367+ //
368+ // We trigger the unsafe-db path deterministically via manual_close_v2,
369+ // and we force the sql buffer into the bit-31-clear address range by
370+ // growing the heap with a rolling allocator before each iteration.
371+ // On a fresh process the heap can start either side of 2^31, so
372+ // without the rolling growth the test is stochastic. With it, every
373+ // iteration fires the bug on unpatched builds.
374+ //
375+ // The patched provider bounds-checks p_tail against
376+ // [p_sql, p_sql + sql.Length] before computing the slice, so this
377+ // returns rc=SQLITE_MISUSE + tail=Empty instead of throwing.
378+ [ Fact ]
379+ public void test_prepare_v2_span_tolerates_uninitialised_pzTail ( )
380+ {
381+ var rolling = new byte [ 256 ] [ ] ;
382+ var rnd = new Random ( 42 ) ;
383+ for ( var i = 0 ; i < 512 ; i ++ )
384+ {
385+ rolling [ i % rolling . Length ] = new byte [ rnd . Next ( 1024 , 128 * 1024 ) ] ;
386+
387+ var db = ugly . open ( ":memory:" ) ;
388+ db . manual_close_v2 ( ) ;
389+ try
390+ {
391+ var sql = u . to_utf8 ( "SELECT 1;" ) ;
392+
393+ int rc = raw . sqlite3_prepare_v2 ( db , sql . AsSpan ( ) , out var stmt , out var tail ) ;
394+
395+ Assert . Equal ( raw . SQLITE_MISUSE , rc ) ;
396+ Assert . True ( tail . IsEmpty ) ;
397+ stmt ? . Dispose ( ) ;
398+ }
399+ finally
400+ {
401+ db . Dispose ( ) ;
402+ }
403+ }
404+ GC . KeepAlive ( rolling ) ;
405+ }
406+
407+ [ Fact ]
408+ public void test_prepare_v3_span_tolerates_uninitialised_pzTail ( )
409+ {
410+ var rolling = new byte [ 256 ] [ ] ;
411+ var rnd = new Random ( 42 ) ;
412+ for ( var i = 0 ; i < 512 ; i ++ )
413+ {
414+ rolling [ i % rolling . Length ] = new byte [ rnd . Next ( 1024 , 128 * 1024 ) ] ;
415+
416+ var db = ugly . open ( ":memory:" ) ;
417+ db . manual_close_v2 ( ) ;
418+ try
419+ {
420+ var sql = u . to_utf8 ( "SELECT 1;" ) ;
421+
422+ int rc = raw . sqlite3_prepare_v3 ( db , sql . AsSpan ( ) , 0 , out var stmt , out var tail ) ;
423+
424+ Assert . Equal ( raw . SQLITE_MISUSE , rc ) ;
425+ Assert . True ( tail . IsEmpty ) ;
426+ stmt ? . Dispose ( ) ;
427+ }
428+ finally
429+ {
430+ db . Dispose ( ) ;
431+ }
432+ }
433+ GC . KeepAlive ( rolling ) ;
434+ }
435+
352436 [ Fact ]
353437 public void test_bind_parameter_index ( )
354438 {
0 commit comments