|
| 1 | +// N1-F0 part B: ownership oracle (00-preregistration.md, F0-B). Research only, throwaway. |
| 2 | +// |
| 3 | +// usage: f0oracle --refs <dir> [--refs <dir>] --group <name>=<file>[;<file>...] [--group ...] --out out.tsv |
| 4 | +// |
| 5 | +// A call result r acquired in method M of type T is OWNED when (rules 1-3 of the pre-registration): |
| 6 | +// 1. M hands r over on the success path to (a) a field f of T, (b) an element of a collection field f |
| 7 | +// (indexer store, Add, TryAdd), or (c) a constructor argument of an in-source type W whose ctor stores |
| 8 | +// it in a field g of W, the W instance being returned or stored as in (a)/(b); |
| 9 | +// 2. the same type (T, or W for (c)) disposes that target: Dispose/DisposeAsync/Close/CloseAsync on f/g, |
| 10 | +// on a local taken from f (TryRemove/Remove out, indexer read, foreach over f or f.Values), or |
| 11 | +// using/await using of one of those; |
| 12 | +// 3. the acquisition is not a borrow (IServiceProvider.GetService and friends). |
| 13 | +// DEFECT: a throwing site (any invocation or await, except nameof/typeof) lies between the acquisition and |
| 14 | +// the hand-over, and no enclosing try disposes r in its catch or finally. |
| 15 | +// "Disposes r" is operationalised (fixed before the first run on real files) as any of: |
| 16 | +// - Dispose/DisposeAsync/Close/CloseAsync on r (also r?.X); |
| 17 | +// - the same call on `new W(r)` where W is an owning wrapper by rules 1c+2; |
| 18 | +// - r passed to a method of T whose body disposes the corresponding parameter (one hop). |
| 19 | +using Microsoft.CodeAnalysis; |
| 20 | +using Microsoft.CodeAnalysis.CSharp; |
| 21 | +using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 22 | + |
| 23 | +var refDirs = new List<string>(); |
| 24 | +var groups = new List<(string Name, string[] Files)>(); |
| 25 | +string? outPath = null; |
| 26 | +for (var i = 0; i < args.Length; i++) |
| 27 | +{ |
| 28 | + switch (args[i]) |
| 29 | + { |
| 30 | + case "--refs": refDirs.Add(args[++i]); break; |
| 31 | + case "--group": |
| 32 | + var g = args[++i].Split('=', 2); |
| 33 | + groups.Add((g[0], g[1].Split(';'))); |
| 34 | + break; |
| 35 | + case "--out": outPath = args[++i]; break; |
| 36 | + default: throw new ArgumentException(args[i]); |
| 37 | + } |
| 38 | +} |
| 39 | +var refs = refDirs.SelectMany(d => Directory.EnumerateFiles(d, "*.dll")) |
| 40 | + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); |
| 41 | +var rows = new List<string> { "group\tfile\ttype\tmethod\tacq_line\tacq\thandover_line\tkind\tdispose_evidence\tthrow_line\tthrow_site" }; |
| 42 | +foreach (var (name, files) in groups) |
| 43 | +{ |
| 44 | + var trees = files.Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), |
| 45 | + new CSharpParseOptions(LanguageVersion.Preview), Path.GetFileName(f))).ToList(); |
| 46 | + var comp = CSharpCompilation.Create(name, trees, refs, |
| 47 | + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); |
| 48 | + var o = new Oracle(comp); |
| 49 | + foreach (var t in trees) o.Run(t); |
| 50 | + foreach (var f in o.Findings) rows.Add(name + "\t" + f); |
| 51 | + Console.Error.WriteLine($"{name}: {o.Findings.Count} findings, {o.OwnedAcquisitions} owned acquisitions, {o.Acquisitions} call acquisitions"); |
| 52 | +} |
| 53 | +File.WriteAllLines(outPath ?? "f0b.tsv", rows); |
| 54 | +foreach (var r in rows) Console.WriteLine(r); |
| 55 | +return 0; |
| 56 | + |
| 57 | +sealed class Oracle(CSharpCompilation comp) |
| 58 | +{ |
| 59 | + public readonly List<string> Findings = new(); |
| 60 | + public int Acquisitions, OwnedAcquisitions; |
| 61 | + |
| 62 | + static readonly HashSet<string> DisposeNames = new() { "Dispose", "DisposeAsync", "Close", "CloseAsync" }; |
| 63 | + static readonly HashSet<string> BorrowNames = new() |
| 64 | + { "GetService", "GetRequiredService", "GetServices", "GetKeyedService", "GetRequiredKeyedService", "GetKeyedServices", "GetServiceOrCreateInstance" }; |
| 65 | + static readonly HashSet<string> StoreNames = new() { "Add", "TryAdd", "AddOrUpdate", "GetOrAdd", "Enqueue", "Push" }; |
| 66 | + |
| 67 | + static int Line(SyntaxNode n) => n.GetLocation().GetLineSpan().StartLinePosition.Line + 1; |
| 68 | + static string Snip(SyntaxNode n) |
| 69 | + { |
| 70 | + var s = string.Join(' ', n.ToString().Split((char[])['\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim())); |
| 71 | + return s.Length > 90 ? s[..90] + "…" : s; |
| 72 | + } |
| 73 | + |
| 74 | + static ExpressionSyntax Strip(ExpressionSyntax e) |
| 75 | + { |
| 76 | + while (true) |
| 77 | + { |
| 78 | + switch (e) |
| 79 | + { |
| 80 | + case AwaitExpressionSyntax a: e = a.Expression; continue; |
| 81 | + case ParenthesizedExpressionSyntax p: e = p.Expression; continue; |
| 82 | + case PostfixUnaryExpressionSyntax { RawKind: (int)SyntaxKind.SuppressNullableWarningExpression } pu: e = pu.Operand; continue; |
| 83 | + case InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "ConfigureAwait" } ma }: e = ma.Expression; continue; |
| 84 | + default: return e; |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + static string? InvokedName(InvocationExpressionSyntax inv) => inv.Expression switch |
| 90 | + { |
| 91 | + MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text, |
| 92 | + SimpleNameSyntax s => s.Identifier.Text, |
| 93 | + MemberBindingExpressionSyntax mb => mb.Name.Identifier.Text, |
| 94 | + _ => null, |
| 95 | + }; |
| 96 | + |
| 97 | + // the root identifier of a receiver / expression (x in x.a.b, this.x, x?.y) |
| 98 | + static string? RootName(ExpressionSyntax e) |
| 99 | + { |
| 100 | + while (true) |
| 101 | + { |
| 102 | + switch (e) |
| 103 | + { |
| 104 | + case MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } m: return m.Name.Identifier.Text; |
| 105 | + case MemberAccessExpressionSyntax m: e = m.Expression; continue; |
| 106 | + case ConditionalAccessExpressionSyntax c: e = c.Expression; continue; |
| 107 | + case ElementAccessExpressionSyntax ea: e = ea.Expression; continue; |
| 108 | + case ParenthesizedExpressionSyntax p: e = p.Expression; continue; |
| 109 | + case PostfixUnaryExpressionSyntax pu: e = pu.Operand; continue; |
| 110 | + case IdentifierNameSyntax id: return id.Identifier.Text; |
| 111 | + default: return null; |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + // receiver of a dispose-family call: `x.Dispose()`, `x?.Dispose()`, `(expr).DisposeAsync()` |
| 117 | + static ExpressionSyntax? DisposeReceiver(InvocationExpressionSyntax inv) |
| 118 | + { |
| 119 | + if (inv.Expression is MemberAccessExpressionSyntax ma && DisposeNames.Contains(ma.Name.Identifier.Text)) return ma.Expression; |
| 120 | + if (inv.Expression is MemberBindingExpressionSyntax mb && DisposeNames.Contains(mb.Name.Identifier.Text) |
| 121 | + && inv.Parent is ConditionalAccessExpressionSyntax ca) return ca.Expression; |
| 122 | + return null; |
| 123 | + } |
| 124 | + |
| 125 | + static IEnumerable<InvocationExpressionSyntax> DisposeCalls(SyntaxNode n) => |
| 126 | + n.DescendantNodesAndSelf().OfType<InvocationExpressionSyntax>().Where(i => DisposeReceiver(i) is not null); |
| 127 | + |
| 128 | + // Rule 2: does type `decl` dispose field `field` (directly, via a local taken from it, or via using)? |
| 129 | + string? DisposesField(TypeDeclarationSyntax decl, string field) |
| 130 | + { |
| 131 | + var fromField = new HashSet<string>(); |
| 132 | + foreach (var arg in decl.DescendantNodes().OfType<ArgumentSyntax>()) |
| 133 | + { |
| 134 | + if (arg.Expression is DeclarationExpressionSyntax { Designation: SingleVariableDesignationSyntax sv } |
| 135 | + && arg.Parent?.Parent is InvocationExpressionSyntax inv && InvokedName(inv) is "TryRemove" or "Remove" or "TryGetValue" or "TryDequeue" or "TryPop" or "TryTake" |
| 136 | + && inv.Expression is MemberAccessExpressionSyntax ma && RootName(ma.Expression) == field) |
| 137 | + fromField.Add(sv.Identifier.Text); |
| 138 | + } |
| 139 | + foreach (var v in decl.DescendantNodes().OfType<VariableDeclaratorSyntax>()) |
| 140 | + if (v.Initializer?.Value is ElementAccessExpressionSyntax ea && RootName(ea.Expression) == field) fromField.Add(v.Identifier.Text); |
| 141 | + foreach (var fe in decl.DescendantNodes().OfType<ForEachStatementSyntax>()) |
| 142 | + if (RootName(fe.Expression) == field) fromField.Add(fe.Identifier.Text); |
| 143 | + foreach (var call in DisposeCalls(decl)) |
| 144 | + { |
| 145 | + var root = RootName(DisposeReceiver(call)!); |
| 146 | + if (root == field || (root is not null && fromField.Contains(root))) return $"{Line(call)}: {Snip(call)}"; |
| 147 | + } |
| 148 | + foreach (var u in decl.DescendantNodes().OfType<LocalDeclarationStatementSyntax>().Where(l => l.UsingKeyword != default)) |
| 149 | + foreach (var v in u.Declaration.Variables) |
| 150 | + if (v.Initializer?.Value is { } init && (RootName(init) == field || (RootName(init) is { } r && fromField.Contains(r)))) |
| 151 | + return $"{Line(u)}: {Snip(u)}"; |
| 152 | + return null; |
| 153 | + } |
| 154 | + |
| 155 | + bool IsField(TypeDeclarationSyntax decl, string name) => |
| 156 | + decl.Members.OfType<FieldDeclarationSyntax>().Any(f => f.Declaration.Variables.Any(v => v.Identifier.Text == name)) |
| 157 | + || decl.Members.OfType<PropertyDeclarationSyntax>().Any(p => p.Identifier.Text == name); |
| 158 | + |
| 159 | + // Rule 1c: in-source wrapper type W whose ctor stores parameter #i into a field g that W disposes. |
| 160 | + (TypeDeclarationSyntax W, string Evidence)? OwningWrapper(ObjectCreationExpressionSyntax oc, int argIndex) |
| 161 | + { |
| 162 | + var tn = oc.Type switch { IdentifierNameSyntax i => i.Identifier.Text, QualifiedNameSyntax q => q.Right.Identifier.Text, _ => null }; |
| 163 | + if (tn is null) return null; |
| 164 | + foreach (var tree in comp.SyntaxTrees) |
| 165 | + foreach (var w in tree.GetRoot().DescendantNodes().OfType<TypeDeclarationSyntax>().Where(t => t.Identifier.Text == tn)) |
| 166 | + foreach (var ctor in w.Members.OfType<ConstructorDeclarationSyntax>()) |
| 167 | + { |
| 168 | + if (argIndex >= ctor.ParameterList.Parameters.Count) continue; |
| 169 | + var p = ctor.ParameterList.Parameters[argIndex].Identifier.Text; |
| 170 | + foreach (var asg in ctor.DescendantNodes().OfType<AssignmentExpressionSyntax>()) |
| 171 | + if (asg.Right is IdentifierNameSyntax { Identifier.Text: var rp } && rp == p && RootName(asg.Left) is { } g && IsField(w, g) |
| 172 | + && DisposesField(w, g) is { } ev) |
| 173 | + return (w, $"{tn}.{g} disposed at {ev}"); |
| 174 | + } |
| 175 | + return null; |
| 176 | + } |
| 177 | + |
| 178 | + public void Run(SyntaxTree tree) |
| 179 | + { |
| 180 | + foreach (var decl in tree.GetRoot().DescendantNodes().OfType<TypeDeclarationSyntax>()) |
| 181 | + foreach (var m in decl.Members.OfType<MethodDeclarationSyntax>()) |
| 182 | + if (m.Body is not null) Scan(decl, m); |
| 183 | + } |
| 184 | + |
| 185 | + void Scan(TypeDeclarationSyntax T, MethodDeclarationSyntax M) |
| 186 | + { |
| 187 | + // acquisitions: `var r = <call>` or `r = <call>`, the call possibly awaited |
| 188 | + var acqs = new List<(string R, SyntaxNode Stmt, InvocationExpressionSyntax Call)>(); |
| 189 | + foreach (var v in M.Body!.DescendantNodes().OfType<VariableDeclaratorSyntax>()) |
| 190 | + if (v.Initializer?.Value is { } init && Strip(init) is InvocationExpressionSyntax inv) |
| 191 | + acqs.Add((v.Identifier.Text, v, inv)); |
| 192 | + foreach (var a in M.Body.DescendantNodes().OfType<AssignmentExpressionSyntax>()) |
| 193 | + if (a.Left is IdentifierNameSyntax id && Strip(a.Right) is InvocationExpressionSyntax inv2 |
| 194 | + && M.Body.DescendantNodes().OfType<VariableDeclaratorSyntax>().Any(v => v.Identifier.Text == id.Identifier.Text)) |
| 195 | + acqs.Add((id.Identifier.Text, a, inv2)); |
| 196 | + foreach (var (r, stmt, call) in acqs) |
| 197 | + { |
| 198 | + if (InvokedName(call) is { } nm && (BorrowNames.Contains(nm) || nm is "nameof")) continue; // rule 3 |
| 199 | + Acquisitions++; |
| 200 | + var after = stmt.Span.End; |
| 201 | + // rule 1: hand-over target on the success path, after the acquisition |
| 202 | + SyntaxNode? hand = null; string? kind = null; string? ev = null; |
| 203 | + foreach (var n in M.Body.DescendantNodes().Where(n => n.SpanStart >= after)) |
| 204 | + { |
| 205 | + if (n is AssignmentExpressionSyntax { Right: var rhs } asg && Strip(rhs) is IdentifierNameSyntax { Identifier.Text: var rr } && rr == r) |
| 206 | + { |
| 207 | + if (asg.Left is ElementAccessExpressionSyntax ea && RootName(ea.Expression) is { } cf && IsField(T, cf) && DisposesField(T, cf) is { } e1) |
| 208 | + { hand = asg; kind = $"1b {cf}[]"; ev = e1; break; } |
| 209 | + if (asg.Left is IdentifierNameSyntax or MemberAccessExpressionSyntax && RootName(asg.Left) is { } ff && ff != r && IsField(T, ff) && DisposesField(T, ff) is { } e2) |
| 210 | + { hand = asg; kind = $"1a {ff}"; ev = e2; break; } |
| 211 | + } |
| 212 | + if (n is InvocationExpressionSyntax si && InvokedName(si) is { } sn && StoreNames.Contains(sn) |
| 213 | + && si.Expression is MemberAccessExpressionSyntax sma && RootName(sma.Expression) is { } sf && IsField(T, sf) |
| 214 | + && si.ArgumentList.Arguments.Any(x => Strip(x.Expression) is IdentifierNameSyntax { Identifier.Text: var ar } && ar == r) |
| 215 | + && DisposesField(T, sf) is { } e3) |
| 216 | + { hand = si; kind = $"1b {sf}.{sn}"; ev = e3; break; } |
| 217 | + if (n is ObjectCreationExpressionSyntax oc && oc.ArgumentList is { } al) |
| 218 | + { |
| 219 | + var idx = al.Arguments.ToList().FindIndex(x => Strip(x.Expression) is IdentifierNameSyntax { Identifier.Text: var ar } && ar == r); |
| 220 | + if (idx >= 0 && OwningWrapper(oc, idx) is { } w |
| 221 | + && (oc.Parent is ReturnStatementSyntax || (oc.Parent is AssignmentExpressionSyntax pa && RootName(pa.Left) is { } wf && IsField(T, wf)))) |
| 222 | + { hand = oc; kind = $"1c {w.W.Identifier.Text}"; ev = w.Evidence; break; } |
| 223 | + } |
| 224 | + } |
| 225 | + if (hand is null) continue; |
| 226 | + OwnedAcquisitions++; |
| 227 | + // defect: a throwing site strictly between the acquisition and the hand-over, not covered by a disposing try |
| 228 | + var throwing = M.Body.DescendantNodes() |
| 229 | + .Where(n => n.SpanStart >= after && n.Span.End <= hand.SpanStart) |
| 230 | + .Where(n => n is InvocationExpressionSyntax inv3 ? InvokedName(inv3) is not ("nameof") : n is AwaitExpressionSyntax) |
| 231 | + .Where(n => !n.Ancestors().Any(a => a == hand)) |
| 232 | + // disposing r cannot leak r (control N6: the dispose inside the catch) |
| 233 | + .Where(n => !(Strip(n is AwaitExpressionSyntax aw ? aw.Expression : (ExpressionSyntax)n) is InvocationExpressionSyntax di |
| 234 | + && DisposeReceiver(di) is { } dr && RootName(dr) == r)) |
| 235 | + .FirstOrDefault(n => !CoveredByDisposingTry(T, n, r, M)); |
| 236 | + if (throwing is null) continue; |
| 237 | + Findings.Add(string.Join('\t', T.SyntaxTree.FilePath, T.Identifier.Text, M.Identifier.Text, Line(stmt), Snip(call), |
| 238 | + Line(hand), kind, ev, Line(throwing), Snip(throwing))); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + bool CoveredByDisposingTry(TypeDeclarationSyntax T, SyntaxNode site, string r, MethodDeclarationSyntax M) |
| 243 | + { |
| 244 | + foreach (var tr in site.Ancestors().OfType<TryStatementSyntax>()) |
| 245 | + { |
| 246 | + if (!tr.Block.Span.Contains(site.Span)) continue; |
| 247 | + var cleanup = tr.Catches.Select(c => (SyntaxNode)c.Block).Concat(tr.Finally is { } f ? new SyntaxNode[] { f.Block } : []); |
| 248 | + if (cleanup.Any(b => DisposesLocal(T, b, r))) return true; |
| 249 | + } |
| 250 | + return false; |
| 251 | + } |
| 252 | + |
| 253 | + bool DisposesLocal(TypeDeclarationSyntax T, SyntaxNode block, string r) |
| 254 | + { |
| 255 | + foreach (var call in DisposeCalls(block)) |
| 256 | + { |
| 257 | + var recv = DisposeReceiver(call)!; |
| 258 | + if (RootName(recv) == r) return true; |
| 259 | + if (recv is ObjectCreationExpressionSyntax oc && oc.ArgumentList is { } al) |
| 260 | + { |
| 261 | + var idx = al.Arguments.ToList().FindIndex(x => Strip(x.Expression) is IdentifierNameSyntax { Identifier.Text: var ar } && ar == r); |
| 262 | + if (idx >= 0 && OwningWrapper(oc, idx) is not null) return true; |
| 263 | + } |
| 264 | + } |
| 265 | + // one hop: r passed to a method of T whose body disposes that parameter |
| 266 | + foreach (var inv in block.DescendantNodes().OfType<InvocationExpressionSyntax>()) |
| 267 | + { |
| 268 | + if (inv.Expression is not IdentifierNameSyntax callee) continue; |
| 269 | + var idx = inv.ArgumentList.Arguments.ToList().FindIndex(x => Strip(x.Expression) is IdentifierNameSyntax { Identifier.Text: var ar } && ar == r); |
| 270 | + if (idx < 0) continue; |
| 271 | + foreach (var md in T.Members.OfType<MethodDeclarationSyntax>().Where(x => x.Identifier.Text == callee.Identifier.Text)) |
| 272 | + { |
| 273 | + if (idx >= md.ParameterList.Parameters.Count) continue; |
| 274 | + var p = md.ParameterList.Parameters[idx].Identifier.Text; |
| 275 | + if (DisposeCalls(md).Any(c => RootName(DisposeReceiver(c)!) == p)) return true; |
| 276 | + } |
| 277 | + } |
| 278 | + return false; |
| 279 | + } |
| 280 | +} |
0 commit comments