@@ -907,6 +907,37 @@ static void EmitOverspans(ExpressionSyntax expr, HashSet<string> tracked, Semant
907907 _ => null ,
908908} ;
909909
910+ // The field name an expression refers to ONLY when it names a field of THIS object — a bare
911+ // `_f` or `this._f`, NOT `other._f` (a same-named field on a DIFFERENT receiver, which plain
912+ // text matching would conflate into a phantom release/use, CodeRabbit). Deliberately syntactic,
913+ // not symbol-bound: the field-UAF corpus uses types that do not resolve in the project-local
914+ // compilation, so binding on the field's TYPE is unreliable — the `this`/bare receiver shape is
915+ // exact regardless.
916+ static string ? ThisFieldName ( ExpressionSyntax expr ) => expr switch
917+ {
918+ IdentifierNameSyntax id => id . Identifier . Text ,
919+ MemberAccessExpressionSyntax m when m . Expression is ThisExpressionSyntax
920+ => m . Name . Identifier . Text ,
921+ _ => null ,
922+ } ;
923+
924+ // Is there a disposed-flag early-return guard (`if (_disposed) return;`, `if (IsDisposed) return;`)
925+ // among a handler body's TOP-LEVEL statements that OPENS before source position `before`? Such a
926+ // guard makes a later disposed-field read safe (the canonical fix), so the field-UAF pass excludes
927+ // it. Tight on purpose (CodeRabbit/Codex): (1) the guard must PRECEDE the read — a guard only after
928+ // the read does not protect it, so that finding still stands; (2) the THEN branch must be an
929+ // IMMEDIATE `return` (the guard's own action), not a `return` buried in a nested/`else` branch; and
930+ // (3) the flag identifier matches "dispos" case-INsensitively, so the PascalCase `IsDisposed` form
931+ // is recognised as well as `_disposed`.
932+ static bool DisposedGuardBefore ( BlockSyntax body , int before ) =>
933+ body . Statements . OfType < IfStatementSyntax > ( ) . Any ( ifs =>
934+ ifs . SpanStart < before
935+ && ifs . Condition . DescendantNodesAndSelf ( ) . OfType < IdentifierNameSyntax > ( )
936+ . Any ( id => id . Identifier . Text . Contains ( "ispos" , StringComparison . OrdinalIgnoreCase ) )
937+ && ( ifs . Statement is ReturnStatementSyntax
938+ || ( ifs . Statement is BlockSyntax gb
939+ && gb . Statements . FirstOrDefault ( ) is ReturnStatementSyntax ) ) ) ;
940+
910941// Is `t` the System.Buffers.ArrayPool<T> type — the Return-based pool we model?
911942// Checked on the resolved SYMBOL, not the receiver's text, so an aliased receiver
912943// (`ArrayPool<int> p = ArrayPool<int>.Shared; p.Rent(n)`) binds correctly and an
@@ -1872,6 +1903,123 @@ or ImplicitObjectCreationExpressionSyntax
18721903 }
18731904 }
18741905
1906+ // P-007 / WPF: a field-mediated cross-method USE-AFTER-DISPOSE. An IDisposable
1907+ // field disposed in this class's Dispose()/DisposeAsync() is then DIRECTLY read
1908+ // (`_f.Member`) in an event-handler method — a callback an external event source
1909+ // can still invoke AFTER the object is disposed (the very reason WPF handler
1910+ // leaks matter). With no `if (_disposed) return;` guard the handler touches a
1911+ // field already disposed: a use-after-dispose. We lower it to a synthetic
1912+ // acquire/release/use flow so the existing OwnIR bridge raises OWN002 at the
1913+ // field — no new diagnostic, no second checker (the synthetic-flow trick the
1914+ // MemoryPool slices use). Precise by construction to stay low-FP: fires only when
1915+ // (a) the field is disposed in the dispose LIFECYCLE (not an ad-hoc `_f.Dispose()`),
1916+ // (b) the touching method is a LIVE subscription target — RHS of a `+=` / arg of
1917+ // a `.Subscribe(...)` — whose subscription is NOT torn down (`-= handler`
1918+ // means the callback cannot fire post-dispose, so it is safe),
1919+ // (c) the method has no disposed-guard (the canonical fix silences it), and
1920+ // (d) the use is a DIRECT field member access (an INDIRECT use via a helper is
1921+ // deliberately not chased — that is the harder frontier, left honest).
1922+ // Gated on --flow-locals like the rest of the synthetic-flow emission.
1923+ if ( flowLocals )
1924+ {
1925+ // IDisposable fields -> declaration line (the synthetic `acquire`).
1926+ var dispoFieldLine = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
1927+ foreach ( var fd in cls . Members . OfType < FieldDeclarationSyntax > ( ) )
1928+ {
1929+ if ( fd . Modifiers . Any ( mm => mm . IsKind ( SyntaxKind . StaticKeyword ) ) )
1930+ continue ;
1931+ if ( ! IsDisposableType ( fd . Declaration . Type . ToString ( ) ) )
1932+ continue ;
1933+ foreach ( var v in fd . Declaration . Variables )
1934+ dispoFieldLine [ v . Identifier . Text ] = LineOf ( v ) ;
1935+ }
1936+ // field -> line of its `.Dispose()` INSIDE Dispose()/DisposeAsync() (the
1937+ // release event). Restricted to the dispose methods so an ordinary
1938+ // `_f.Dispose()` helper is not misread as object teardown.
1939+ var releasedAt = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
1940+ if ( dispoFieldLine . Count > 0 )
1941+ foreach ( var dm in cls . Members . OfType < MethodDeclarationSyntax > ( ) )
1942+ {
1943+ if ( dm . Identifier . Text is not ( "Dispose" or "DisposeAsync" ) )
1944+ continue ;
1945+ foreach ( var inv in dm . DescendantNodes ( ) . OfType < InvocationExpressionSyntax > ( ) )
1946+ if ( inv . Expression is MemberAccessExpressionSyntax dmm
1947+ && dmm . Name . Identifier . Text is "Dispose" or "DisposeAsync"
1948+ && ThisFieldName ( dmm . Expression ) is { } df
1949+ && dispoFieldLine . ContainsKey ( df )
1950+ && ! releasedAt . ContainsKey ( df ) )
1951+ releasedAt [ df ] = LineOf ( inv ) ;
1952+ }
1953+ if ( releasedAt . Count > 0 )
1954+ {
1955+ // handler method names that are LIVE subscription targets. `+=` subscriptions are
1956+ // keyed by SOURCE|handler so a `-=` removes only the MATCHING one — a handler still
1957+ // `+=`'d to another live source stays live (a name-only set would let one `-=` drop
1958+ // it globally, CodeRabbit/Codex). A `.Subscribe(handler)` token is released by
1959+ // disposing the token (the Rx idiom), not a `-=`, so those handlers are always live.
1960+ var liveEventKeys = new HashSet < string > ( StringComparer . Ordinal ) ;
1961+ foreach ( var a in assigns )
1962+ if ( IsHandler ( a . Right ) && FieldName ( a . Right ) is { } hn )
1963+ {
1964+ var key = $ "{ a . Left } |{ hn } ";
1965+ if ( a . IsKind ( SyntaxKind . AddAssignmentExpression ) ) liveEventKeys . Add ( key ) ;
1966+ else if ( a . IsKind ( SyntaxKind . SubtractAssignmentExpression ) ) liveEventKeys . Remove ( key ) ;
1967+ }
1968+ var subscribed = new HashSet < string > (
1969+ liveEventKeys . Select ( k => k [ ( k . LastIndexOf ( '|' ) + 1 ) ..] ) , StringComparer . Ordinal ) ;
1970+ foreach ( var inv in cls . DescendantNodes ( ) . OfType < InvocationExpressionSyntax > ( ) )
1971+ if ( inv . Expression is MemberAccessExpressionSyntax sm
1972+ && sm . Name . Identifier . Text == "Subscribe" )
1973+ foreach ( var arg in inv . ArgumentList . Arguments )
1974+ if ( FieldName ( arg . Expression ) is { } hn )
1975+ subscribed . Add ( hn ) ;
1976+
1977+ foreach ( var hm in cls . Members . OfType < MethodDeclarationSyntax > ( ) )
1978+ {
1979+ if ( hm . Body is not { } hbody )
1980+ continue ;
1981+ var hname = hm . Identifier . Text ;
1982+ if ( hname is "Dispose" or "DisposeAsync" )
1983+ continue ;
1984+ if ( ! subscribed . Contains ( hname ) )
1985+ continue ;
1986+ // the FIRST direct read of a disposed field of THIS class (`_f` / `this._f`,
1987+ // not `other._f`) in the handler, if any.
1988+ MemberAccessExpressionSyntax ? use = null ;
1989+ string ? useField = null ;
1990+ foreach ( var ma in hbody . DescendantNodes ( ) . OfType < MemberAccessExpressionSyntax > ( ) )
1991+ {
1992+ if ( ma . Name . Identifier . Text is "Dispose" or "DisposeAsync" )
1993+ continue ;
1994+ if ( ThisFieldName ( ma . Expression ) is { } uf && releasedAt . ContainsKey ( uf ) )
1995+ {
1996+ use = ma ;
1997+ useField = uf ;
1998+ break ;
1999+ }
2000+ }
2001+ // no direct disposed-field read, or an opening disposed-guard PRECEDES it ->
2002+ // not a use-after-dispose (an INDIRECT use via a helper is left an honest miss).
2003+ // Otherwise emit ONE synthetic acquire/release/use flow -> OWN002 via the bridge.
2004+ if ( use is null || useField is null )
2005+ continue ;
2006+ if ( DisposedGuardBefore ( hbody , use . SpanStart ) )
2007+ continue ;
2008+ flowFunctions . Add ( new
2009+ {
2010+ name = $ "{ cls . Identifier . Text } .{ hname } ",
2011+ file ,
2012+ body = new List < object >
2013+ {
2014+ new { op = "acquire" , var = useField , line = dispoFieldLine [ useField ] } ,
2015+ new { op = "release" , var = useField , line = releasedAt [ useField ] } ,
2016+ new { op = "use" , var = useField , line = LineOf ( use ) } ,
2017+ } ,
2018+ } ) ;
2019+ }
2020+ }
2021+ }
2022+
18752023 // WPF004: a `X.Subscribe(...)` whose IDisposable result is ignored — the
18762024 // call stands as a bare statement (not assigned/returned/added), so the
18772025 // token is dropped and never disposed. Member-access only (`x.Subscribe`),
0 commit comments