@@ -3420,3 +3420,187 @@ fn call_tool_defaults_missing_args_to_empty_object() {
34203420 Ok ( "nil" . to_string( ) )
34213421 ) ;
34223422}
3423+
3424+ // --- dirge-eona stress harness --------------------------------------
3425+ //
3426+ // Not a correctness test — a repro driver for the Janet-heap
3427+ // use-after-free that SIGSEGVs the plugin worker (dirge-eona). It
3428+ // replays the workload that precedes the crash: hook dispatch across a
3429+ // session with a response-capturing plugin loaded, then a slash-command
3430+ // invocation, under explicit GC pressure. Ignored so the normal suite
3431+ // never runs it; drive it explicitly:
3432+ //
3433+ // cargo test --bin dirge plugin_worker_uaf_stress -- --ignored --nocapture
3434+ //
3435+ // and under lldb for a symbolicated backtrace at the fault.
3436+ //
3437+ // Knobs (env): DIRGE_STRESS_TURNS (default 200),
3438+ // DIRGE_STRESS_UPDATES (25 per turn), DIRGE_STRESS_RESPONSE_BYTES
3439+ // (20000), DIRGE_STRESS_USER_PLUGINS (1 = load ~/.config/dirge/plugins,
3440+ // exactly the crashing session's env).
3441+ #[ cfg( feature = "plugin" ) ]
3442+ #[ test]
3443+ #[ ignore]
3444+ fn plugin_worker_uaf_stress ( ) {
3445+ fn env_usize ( name : & str , default : usize ) -> usize {
3446+ std:: env:: var ( name)
3447+ . ok ( )
3448+ . and_then ( |v| v. parse ( ) . ok ( ) )
3449+ . unwrap_or ( default)
3450+ }
3451+ let turns = env_usize ( "DIRGE_STRESS_TURNS" , 200 ) ;
3452+ let updates = env_usize ( "DIRGE_STRESS_UPDATES" , 25 ) ;
3453+ let response_bytes = env_usize ( "DIRGE_STRESS_RESPONSE_BYTES" , 20_000 ) ;
3454+ let load_user_plugins = std:: env:: var ( "DIRGE_STRESS_USER_PLUGINS" )
3455+ . map ( |v| v != "0" )
3456+ . unwrap_or ( true ) ;
3457+
3458+ let mut mgr = PluginManager :: try_new ( ) . unwrap ( ) ;
3459+
3460+ // The crashing sessions had the user's whole plugin dir loaded
3461+ // (~38 entries, several of them response-capturing). Reproduce that
3462+ // env; per-plugin failures are not the point of the harness.
3463+ if load_user_plugins && let Ok ( home) = std:: env:: var ( "HOME" ) {
3464+ let dir = std:: path:: PathBuf :: from ( home) . join ( ".config/dirge/plugins" ) ;
3465+ if dir. is_dir ( ) {
3466+ let mut entries: Vec < _ > = std:: fs:: read_dir ( & dir)
3467+ . unwrap ( )
3468+ . filter_map ( |e| e. ok ( ) . map ( |e| e. path ( ) ) )
3469+ . filter ( |p| p. extension ( ) . is_some_and ( |e| e == "janet" ) || p. is_dir ( ) )
3470+ . collect ( ) ;
3471+ entries. sort ( ) ;
3472+ for path in entries {
3473+ let _ = load_plugin ( & mut mgr, & path) ;
3474+ }
3475+ }
3476+ }
3477+
3478+ // Realistic response material: markdown + code fences + quotes +
3479+ // backslashes + unicode, so the ctx escaper sees the same shape of
3480+ // text the crashing sessions carried.
3481+ let unit = "Some **markdown** with `code` and \" quotes\" and \\ \\ paths — ünïcødé ✓\n ```rust\n fn main() { println!(\" line\" ); }\n ```\n " ;
3482+ let big_response = unit. repeat ( response_bytes / unit. len ( ) + 1 ) ;
3483+
3484+ for turn in 0 ..turns {
3485+ mgr. dispatch ( "on-prompt" , "@{:prompt \" stress turn\" }" )
3486+ . unwrap ( ) ;
3487+ mgr. dispatch ( "on-turn-start" , "@{}" ) . unwrap ( ) ;
3488+ for u in 0 ..updates {
3489+ let partial_len = turn * 100 + u * 50 ;
3490+ let mut pend = partial_len. min ( big_response. len ( ) ) ;
3491+ while pend > 0 && !big_response. is_char_boundary ( pend) {
3492+ pend -= 1 ;
3493+ }
3494+ let partial = & big_response[ ..pend] ;
3495+ let ctx = format ! (
3496+ "@{{:index {} :partial \" {}\" }}" ,
3497+ u,
3498+ escape_janet_string( partial)
3499+ ) ;
3500+ mgr. dispatch ( "on-message-update" , & ctx) . unwrap ( ) ;
3501+ }
3502+ let mut rend = ( response_bytes + turn * 10 ) . min ( big_response. len ( ) ) ;
3503+ while rend > 0 && !big_response. is_char_boundary ( rend) {
3504+ rend -= 1 ;
3505+ }
3506+ let response = & big_response[ ..rend] ;
3507+ let ctx = format ! ( "@{{:response \" {}\" }}" , escape_janet_string( response) ) ;
3508+ mgr. dispatch ( "on-response" , & ctx) . unwrap ( ) ;
3509+ // Force collection every turn so freed slots get reused — the
3510+ // dirge-eona window. No-op turns: a normal, collected GC cycle.
3511+ mgr. eval ( "(gccollect)" ) . unwrap ( ) ;
3512+ let r = mgr. invoke_command ( "resp-clipboard-handler" , "" ) . unwrap ( ) ;
3513+ if turn % 50 == 0 {
3514+ println ! (
3515+ "turn {turn}: {:?}" ,
3516+ r. map( |s| s. chars( ) . take( 60 ) . collect:: <String >( ) )
3517+ ) ;
3518+ }
3519+ }
3520+ println ! ( "stress completed without crashing: {turns} turns" ) ;
3521+ }
3522+
3523+ /// dirge-eona: pins the `JanetVMPrefix` layout assumption that the fix rests
3524+ /// on. `top_dyns_ptr` reads offset 8 of a struct whose definition is private
3525+ /// to janet.c, and `janetrs` depends on `evil-janet = "1"`, so a `cargo
3526+ /// update` can move the field under us. A wrong offset would not fail the
3527+ /// build — it would hand `janet_gcroot` whatever now lives there, which is
3528+ /// far worse than the crash it replaced. The stress harness above cannot
3529+ /// catch that: it is `#[ignore]`d and needs the user's plugin dir.
3530+ ///
3531+ /// This runs in the normal suite instead. It owns a bare VM on its own
3532+ /// thread (no default env, so nothing but this test writes a dyn), and leans
3533+ /// on the fact that no fiber is live here: `janet_setdyn` therefore writes
3534+ /// `top_dyns` (janet.c:4657), which is the same state the worker mirrors in.
3535+ ///
3536+ /// The null-before assertion is what discriminates the field: every nearby
3537+ /// `JanetTable *` in `struct JanetVM` behaves differently across these
3538+ /// steps. `abstract_registry` is already non-null after `janet_init`
3539+ /// (janet.c:35577), and `core_env` stays null and never gains entries from
3540+ /// `janet_setdyn`.
3541+ #[ cfg( feature = "plugin" ) ]
3542+ #[ test]
3543+ fn top_dyns_offset_is_stable ( ) {
3544+ use janetrs:: lowlevel:: { janet_equals, janet_setdyn, janet_table, janet_wrap_table} ;
3545+
3546+ let _client = janetrs:: client:: JanetClient :: init ( ) . expect ( "bare VM on a fresh test thread" ) ;
3547+
3548+ assert ! (
3549+ worker:: top_dyns_ptr( ) . is_null( ) ,
3550+ "top_dyns must start null (janet_init sets it NULL, janet.c:35592); \
3551+ a non-null read here means offset 8 is no longer top_dyns"
3552+ ) ;
3553+
3554+ // A table, not a number: `janet_wrap_integer` is a macro under some
3555+ // nanbox configs and is not exported from libjanet, so it does not link.
3556+ // SAFETY: the VM is initialized on this thread, so `janet_table`
3557+ // allocates on its heap and `janet_wrap_table` only tags the pointer.
3558+ // Nothing collects during this test, so the value stays live unrooted.
3559+ let probe = unsafe { janet_wrap_table ( janet_table ( 0 ) ) } ;
3560+ // SAFETY: VM live on this thread and no fiber is running, so
3561+ // `janet_setdyn` lazily creates `top_dyns` and writes into it.
3562+ unsafe { janet_setdyn ( c"dirge-eona-probe" . as_ptr ( ) , probe) } ;
3563+
3564+ let top = worker:: top_dyns_ptr ( ) ;
3565+ assert ! (
3566+ !top. is_null( ) ,
3567+ "the first janet_setdyn must create top_dyns"
3568+ ) ;
3569+ // SAFETY: `top` is the table janet_setdyn just created; reading `count`
3570+ // is an in-bounds field read on a live JanetTable.
3571+ assert_eq ! (
3572+ unsafe { ( * top) . count } ,
3573+ 1 ,
3574+ "the table at offset 8 must be the one janet_setdyn wrote to"
3575+ ) ;
3576+
3577+ // A second key lands in the SAME table, and the pointer does not move.
3578+ // SAFETY: as above — VM live on this thread, still no fiber.
3579+ unsafe { janet_setdyn ( c"dirge-eona-probe-2" . as_ptr ( ) , probe) } ;
3580+ assert_eq ! (
3581+ worker:: top_dyns_ptr( ) ,
3582+ top,
3583+ "top_dyns is created once, then reused (janet.c:4657)"
3584+ ) ;
3585+ // SAFETY: as above.
3586+ assert_eq ! (
3587+ unsafe { ( * top) . count } ,
3588+ 2 ,
3589+ "both dyns must be in this table"
3590+ ) ;
3591+
3592+ // And it is the table the public reader consults: with no fiber live,
3593+ // `janet_dyn` reads `top_dyns` directly (janet.c:4645).
3594+ // SAFETY: VM live on this thread, and both operands are live Janet
3595+ // values — `probe` and whatever the dyn lookup returns for its key.
3596+ assert_eq ! (
3597+ unsafe {
3598+ janet_equals(
3599+ janetrs:: lowlevel:: janet_dyn( c"dirge-eona-probe" . as_ptr( ) ) ,
3600+ probe,
3601+ )
3602+ } ,
3603+ 1 ,
3604+ "janet_dyn must read back what janet_setdyn wrote"
3605+ ) ;
3606+ }
0 commit comments