@@ -1173,3 +1173,113 @@ func TestLoadConfigInvalidYAML(t *testing.T) {
11731173 t .Error ("expected error for invalid YAML" )
11741174 }
11751175}
1176+
1177+ // TestInvokeByNameErrorDoesNotUAFContextTable is the regression guard
1178+ // for P0-1. Pre-fix, `cpex_invoke` consumed the input ContextTable's
1179+ // Box mid-function but didn't write *context_table_out on
1180+ // RC_TIMEOUT / RC_PANIC / RC_PARSE_ERROR — the Go wrapper kept its
1181+ // stale handle and a subsequent Close() called
1182+ // cpex_release_context_table on already-freed memory.
1183+ //
1184+ // Post-fix, the Go wrapper nils its `contextTable.handle` immediately
1185+ // after handing it to Rust (mirroring the bg_handle pattern), so even
1186+ // if Rust errors out without producing a replacement, no dangling
1187+ // handle survives.
1188+ //
1189+ // This test:
1190+ // 1. Performs a successful invoke to get a real ContextTable.
1191+ // 2. Calls InvokeByName again with that ContextTable PLUS an
1192+ // invalid payload_type that forces Rust to return RC_PARSE_ERROR
1193+ // AFTER the consumption point.
1194+ // 3. Confirms the second call errored (sanity).
1195+ // 4. Calls Close() on the original ContextTable — must NOT crash.
1196+ // Pre-fix this was a UAF (free of already-freed memory).
1197+ func TestInvokeByNameErrorDoesNotUAFContextTable (t * testing.T ) {
1198+ mgr , err := NewPluginManagerDefault ()
1199+ if err != nil {
1200+ t .Fatalf ("NewPluginManagerDefault failed: %v" , err )
1201+ }
1202+ defer mgr .Shutdown ()
1203+ if err := mgr .Initialize (); err != nil {
1204+ t .Fatalf ("Initialize failed: %v" , err )
1205+ }
1206+
1207+ // 1. Successful first invoke — gives us a real, Rust-allocated
1208+ // ContextTable. Calling Close() on this pre-fix would have
1209+ // been the second free.
1210+ payload := map [string ]any {"tool_name" : "test" }
1211+ _ , ctxTable , bg , err := mgr .InvokeByName ("hook1" , PayloadGeneric , payload , & Extensions {}, nil )
1212+ if err != nil {
1213+ t .Fatalf ("first invoke failed: %v" , err )
1214+ }
1215+ bg .Close ()
1216+ if ctxTable == nil || ctxTable .handle == nil {
1217+ t .Fatal ("expected a non-nil ContextTable from the first invoke" )
1218+ }
1219+
1220+ // 2. Second invoke with an UNKNOWN payload_type (99). The Rust
1221+ // side validates payload_type against its registry; an
1222+ // unknown value forces a RC_PARSE_ERROR return. Critically,
1223+ // that error path is now POST-consumption of the input
1224+ // context_table.
1225+ const unknownPayloadType uint8 = 99
1226+ _ , _ , _ , err = mgr .InvokeByName ("hook2" , unknownPayloadType , payload , & Extensions {}, ctxTable )
1227+ if err == nil {
1228+ t .Fatal ("expected error from invoke with unknown payload_type" )
1229+ }
1230+
1231+ // 3. Per the P0-1 contract, ctxTable's handle was nil'd in Go
1232+ // *before* the C call returned. So whether or not Rust wrote
1233+ // *context_table_out, our local handle is nil.
1234+ if ctxTable .handle != nil {
1235+ t .Errorf ("input ContextTable.handle should be nil after invoke error; got %p" , ctxTable .handle )
1236+ }
1237+
1238+ // 4. The actual UAF check: Close() must be safe. Pre-fix this
1239+ // called cpex_release_context_table on already-freed memory.
1240+ // Post-fix, Close() short-circuits on a nil handle and is a
1241+ // no-op. Either it crashes (fail) or it doesn't (pass).
1242+ ctxTable .Close ()
1243+ }
1244+
1245+ // TestInvokeByNameConsumesContextTableOnRcError pins the other half
1246+ // of the P0-1 contract — even when the manager rejects the call with
1247+ // a validation-class error (here: shutdown after first invoke), the
1248+ // caller's ContextTable handle is nil'd unconditionally.
1249+ //
1250+ // Verifies: no leak of the input Box when Rust never gets to write
1251+ // the output; Close() on the input is a safe no-op.
1252+ func TestInvokeByNameConsumesContextTableEvenOnShutdownPath (t * testing.T ) {
1253+ mgr , err := NewPluginManagerDefault ()
1254+ if err != nil {
1255+ t .Fatalf ("NewPluginManagerDefault failed: %v" , err )
1256+ }
1257+ if err := mgr .Initialize (); err != nil {
1258+ mgr .Shutdown ()
1259+ t .Fatalf ("Initialize failed: %v" , err )
1260+ }
1261+
1262+ payload := map [string ]any {"tool_name" : "test" }
1263+ _ , ctxTable , bg , err := mgr .InvokeByName ("hook1" , PayloadGeneric , payload , & Extensions {}, nil )
1264+ if err != nil {
1265+ mgr .Shutdown ()
1266+ t .Fatalf ("first invoke failed: %v" , err )
1267+ }
1268+ bg .Close ()
1269+
1270+ // Shut down the manager — Go-side short-circuit will return
1271+ // ErrCpexInvalidHandle WITHOUT calling cpex_invoke. The Go
1272+ // wrapper hasn't touched ctxTable yet in this case (early
1273+ // return at m.handle == nil), so ctxTable.handle remains live.
1274+ mgr .Shutdown ()
1275+
1276+ _ , _ , _ , err = mgr .InvokeByName ("hook2" , PayloadGeneric , payload , & Extensions {}, ctxTable )
1277+ if ! errors .Is (err , ErrCpexInvalidHandle ) {
1278+ t .Errorf ("expected ErrCpexInvalidHandle after shutdown, got %v" , err )
1279+ }
1280+
1281+ // Even though the Go-side short-circuit didn't transit our
1282+ // handle to Rust, Close() must still be safe — it's a legal
1283+ // thing for callers to do.
1284+ ctxTable .Close ()
1285+ }
0 commit comments