diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 73b08dfe..ba602b2c 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -2260,9 +2260,15 @@ void Visit(ExpressionSyntax e) // IncrementalHash.CreateHash(), ... (guarded by static + Create-prefixed + the RESULT // implementing IDisposable + the crypto namespace, so an instance `CreateEncryptor()` or // a non-IDisposable `CreateFromName()` is never mistaken for one). +// * P1a stdlib pack: System.Xml `XmlReader.Create`/`XmlWriter.Create` -> a fresh owned +// reader/writer; System.Text.Json `JsonDocument.Parse` -> a JsonDocument that pools memory +// and must be disposed. Gated on the RESULT implementing IDisposable, so a Task-returning +// `JsonDocument.ParseAsync` is excluded. // Curated + symbol-resolved, so a borrowed/cached disposable handed back by some other API is // never mistaken for an owned acquire (precision over recall — the set grows only as -// ownership is certain). +// ownership is certain). Kept in lockstep with the bridge table `_BCL_FRESH_BY_NS` +// (ownlang/ownir.py): the extractor decides whether to EMIT the factory call fact, the bridge +// recognises the callee name as `fresh`. static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) { if (e is not InvocationExpressionSyntax i @@ -2278,6 +2284,35 @@ static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) && ImplementsIDisposable(sym.ReturnType) && IsInNamespace(sym.ContainingType, "System", "Security", "Cryptography")) return true; + if (sym.IsStatic && ImplementsIDisposable(sym.ReturnType) + && ((sym.Name == "Create" + && sym.ContainingType is { Name: "XmlReader" or "XmlWriter" } xt + && IsInNamespace(xt, "System", "Xml")) + || (sym.Name == "Parse" + && sym.ContainingType is { Name: "JsonDocument" } jt + && IsInNamespace(jt, "System", "Text", "Json"))) + // ...but ONLY the overloads that take no disposable input. The Stream/TextReader/ + // TextWriter overloads do NOT own that input (XmlReaderSettings.CloseInput / + // XmlWriterSettings.CloseOutput default false, JsonDocument.Parse never closes its + // stream), so claiming a pure factory there would let the escape pass drop the + // caller-owned input and suppress its leak (Codex). The common string/URI/path + // overloads have no disposable arg and still resolve. (precision over recall.) + && !AnyDisposableArgument(i, model)) + return true; + return false; +} + +// True if any argument to the call resolves to a type implementing IDisposable — a disposable +// the callee might NOT take ownership of, so an enclosing owned-factory claim must be declined +// rather than silently drop that argument from leak tracking. +static bool AnyDisposableArgument(InvocationExpressionSyntax inv, SemanticModel model) +{ + foreach (var arg in inv.ArgumentList.Arguments) + { + var t = model.GetTypeInfo(arg.Expression).Type; + if (t is not null && ImplementsIDisposable(t)) + return true; + } return false; } diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 5d99b4b1..0cac20f0 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -1185,6 +1185,17 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str], "SHA1.Create", "SHA256.Create", "SHA384.Create", "SHA512.Create", "MD5.Create", "Aes.Create", "RSA.Create", "ECDsa.Create", ), + # P1a (stdlib contract pack): more well-known static factories whose result the caller + # OWNS and must dispose. `XmlReader`/`XmlWriter.Create` return a fresh reader/writer; a + # `JsonDocument` from `Parse` pools memory and must be disposed (a `var doc = + # JsonDocument.Parse(json)` that drops `doc` is a common real leak). Kept in lockstep with + # the extractor's `IsOwningFactory` (frontend/roslyn/.../Program.cs). + "System.Xml": ( + "XmlReader.Create", "XmlWriter.Create", + ), + "System.Text.Json": ( + "JsonDocument.Parse", + ), } _BCL_FRESH_FACTORIES = frozenset(e for es in _BCL_FRESH_BY_NS.values() for e in es) # the fully-qualified identities (each under its real namespace), accepted beside the bare forms. diff --git a/tests/test_ownir.py b/tests/test_ownir.py index bcb800d0..60ae713c 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -1691,6 +1691,25 @@ def _bcl(body: list) -> list: if _bcl([{"op": "call", "callee": "global::MyCompany.File.OpenRead", "args": ["p"], "result": "s", "line": 4}]): fails.append("Tier B precision: `global::`-qualified non-System.IO must NOT match") + # P1a (stdlib pack): more curated owned-returning factories. A dropped XmlReader/XmlWriter/ + # JsonDocument result leaks at the factory call (OWN001), the same producer-side contract as + # File.Open* — both the bare `Type.Method` and the namespace-qualified identity resolve. + for fresh_callee, ln in (("XmlReader.Create", 5), + ("System.Xml.XmlReader.Create", 6), + ("XmlWriter.Create", 7), + ("System.Xml.XmlWriter.Create", 8), + ("JsonDocument.Parse", 9), + ("System.Text.Json.JsonDocument.Parse", 10)): + checks += 1 + leak = [(x.code, x.line) for x in _bcl( + [{"op": "call", "callee": fresh_callee, "args": ["a"], "result": "s", "line": ln}])] + if leak != [("OWN001", ln)]: + fails.append(f"P1a: a leaked `{fresh_callee}` result must be OWN001@{ln}, got {leak}") + # disposing the P1a factory result is clean (no false leak), proving it is a real acquire. + checks += 1 + if _bcl([{"op": "call", "callee": "XmlReader.Create", "args": ["a"], "result": "s", "line": 5}, + {"op": "release", "var": "s", "line": 6}]): + fails.append("P1a: a disposed XmlReader.Create result must be clean (silent)") checks += 1 # OVERRIDE (Codex): a first-party summary is authoritative — a first-party `File.OpenRead` # that returns its parameter is NOT fresh, so a caller dropping its result is clean; the