diff --git a/corpus/real-world/socket-accept-leak/after.cs b/corpus/real-world/socket-accept-leak/after.cs new file mode 100644 index 00000000..74a99ffd --- /dev/null +++ b/corpus/real-world/socket-accept-leak/after.cs @@ -0,0 +1,10 @@ +using System.Net.Sockets; + +static class SocketAcceptLeak +{ + static bool Serve(Socket listener) + { + using var conn = listener.Accept(); // disposed at scope exit -> clean + return conn.Connected; + } +} diff --git a/corpus/real-world/socket-accept-leak/before.cs b/corpus/real-world/socket-accept-leak/before.cs new file mode 100644 index 00000000..18a721ce --- /dev/null +++ b/corpus/real-world/socket-accept-leak/before.cs @@ -0,0 +1,13 @@ +using System.Net.Sockets; + +// A Socket returned by Socket.Accept() is a fresh owned IDisposable the caller must dispose; +// dropping it leaks the accepted connection (handle held until finalization). The listening +// socket is a borrowed parameter, so the ONLY leak is `conn`. +static class SocketAcceptLeak +{ + static bool Serve(Socket listener) + { + var conn = listener.Accept(); // fresh owned Socket -> OWN001 (never disposed) + return conn.Connected; // used, but never disposed -> leak + } +} diff --git a/corpus/real-world/socket-accept-leak/case.own b/corpus/real-world/socket-accept-leak/case.own new file mode 100644 index 00000000..12052ffb --- /dev/null +++ b/corpus/real-world/socket-accept-leak/case.own @@ -0,0 +1,15 @@ +// OwnLang model of the Socket.Accept() accept-loop leak (owned-API tranche). The accepted Socket +// is a fresh owned IDisposable, used and never released -> OWN001. See notes.md. +module Corpus +resource Conn { + acquire accept + release dispose + kind "disposable" + emit_type "Socket" + emit_acquire "{args}.Accept()" + emit_release "{0}.Dispose()" +} +fn Serve(listener: int) { + let conn = acquire Conn(listener); // var conn = listener.Accept() + // conn used (conn.Connected); no `release conn;` -> OWN001 +} diff --git a/corpus/real-world/socket-accept-leak/expected-diagnostics.txt b/corpus/real-world/socket-accept-leak/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/socket-accept-leak/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/socket-accept-leak/notes.md b/corpus/real-world/socket-accept-leak/notes.md new file mode 100644 index 00000000..42dde103 --- /dev/null +++ b/corpus/real-world/socket-accept-leak/notes.md @@ -0,0 +1,8 @@ +# socket-accept-leak + +`Socket.Accept()` returns a fresh **owned** `Socket` the caller must dispose; dropping it leaks the +accepted connection. Covers the `Socket.Accept` branch of the accept-loop owned-API rule (sibling +to `tcplistener-accept-leak`). + +- **before.cs** — `var conn = listener.Accept();` used and never disposed → `OWN001`. +- **after.cs** — `using var conn = …` → clean. diff --git a/corpus/real-world/tcplistener-accept-leak/after.cs b/corpus/real-world/tcplistener-accept-leak/after.cs new file mode 100644 index 00000000..601976e5 --- /dev/null +++ b/corpus/real-world/tcplistener-accept-leak/after.cs @@ -0,0 +1,11 @@ +using System.Net.Sockets; + +// FIX: own the accepted client for the scope with `using`, so it is disposed on every exit path. +static class AcceptLeak +{ + static bool Serve(TcpListener listener) + { + using var client = listener.AcceptTcpClient(); // disposed at scope exit -> clean + return client.Connected; + } +} diff --git a/corpus/real-world/tcplistener-accept-leak/before.cs b/corpus/real-world/tcplistener-accept-leak/before.cs new file mode 100644 index 00000000..ab88672d --- /dev/null +++ b/corpus/real-world/tcplistener-accept-leak/before.cs @@ -0,0 +1,14 @@ +using System.Net.Sockets; + +// A TcpClient returned by TcpListener.AcceptTcpClient() is a fresh owned IDisposable the caller +// must dispose; dropping it leaks the accepted connection (the socket handle is held until +// finalization) — a classic accept-loop server leak. The listener is a borrowed parameter, so the +// ONLY leak is `client`. +static class AcceptLeak +{ + static bool Serve(TcpListener listener) + { + var client = listener.AcceptTcpClient(); // fresh owned TcpClient -> OWN001 (never disposed) + return client.Connected; // used, but never disposed -> leak + } +} diff --git a/corpus/real-world/tcplistener-accept-leak/case.own b/corpus/real-world/tcplistener-accept-leak/case.own new file mode 100644 index 00000000..40063b1c --- /dev/null +++ b/corpus/real-world/tcplistener-accept-leak/case.own @@ -0,0 +1,18 @@ +// OwnLang model of the canonical TCP accept-loop leak (owned-API tranche). A TcpClient from +// TcpListener.AcceptTcpClient() is a fresh owned IDisposable the caller must dispose; here it is +// accepted, used, and never released — the generic OWN001 leak. The listener is a borrowed +// parameter and the client does not escape, so it stays tracked. Sibling accept members +// (Socket.Accept, TcpListener.AcceptSocket) are recognised the same way. See notes.md. +module Corpus +resource Client { + acquire accept + release dispose + kind "disposable" + emit_type "TcpClient" + emit_acquire "{args}.AcceptTcpClient()" + emit_release "{0}.Dispose()" +} +fn Serve(listener: int) { + let client = acquire Client(listener); // var client = listener.AcceptTcpClient() + // client used (client.Connected); no `release client;` — never disposed (OWN001) +} diff --git a/corpus/real-world/tcplistener-accept-leak/expected-diagnostics.txt b/corpus/real-world/tcplistener-accept-leak/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/tcplistener-accept-leak/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/tcplistener-accept-leak/notes.md b/corpus/real-world/tcplistener-accept-leak/notes.md new file mode 100644 index 00000000..f2711469 --- /dev/null +++ b/corpus/real-world/tcplistener-accept-leak/notes.md @@ -0,0 +1,15 @@ +# tcplistener-accept-leak + +`TcpListener.AcceptTcpClient()` returns a fresh **owned** `TcpClient` the caller must dispose. +Dropping it leaks the accepted connection — the socket handle stays open until finalization, the +classic accept-loop server resource leak. + +- **before.cs** — `var client = listener.AcceptTcpClient();` used and never disposed → `OWN001`. + The listener is a borrowed parameter, so the only leak is the client. +- **after.cs** — `using var client = …` disposes it on every path → clean. + +Recognised by the extractor's `IsOwningFactory` (owned-API tranche): an instance "accept" member +matched by the concrete BCL receiver type + method name (`Socket.Accept`, +`TcpListener.AcceptSocket`, `TcpListener.AcceptTcpClient` in `System.Net.Sockets`) with the result +pinned to `IDisposable`, so the async variants (`AcceptTcpClientAsync` → `Task`/`ValueTask`) are +excluded. diff --git a/corpus/real-world/tcplistener-acceptsocket-leak/after.cs b/corpus/real-world/tcplistener-acceptsocket-leak/after.cs new file mode 100644 index 00000000..dce3d96a --- /dev/null +++ b/corpus/real-world/tcplistener-acceptsocket-leak/after.cs @@ -0,0 +1,10 @@ +using System.Net.Sockets; + +static class AcceptSocketLeak +{ + static bool Serve(TcpListener listener) + { + using var sock = listener.AcceptSocket(); // disposed at scope exit -> clean + return sock.Connected; + } +} diff --git a/corpus/real-world/tcplistener-acceptsocket-leak/before.cs b/corpus/real-world/tcplistener-acceptsocket-leak/before.cs new file mode 100644 index 00000000..6ae9c4c8 --- /dev/null +++ b/corpus/real-world/tcplistener-acceptsocket-leak/before.cs @@ -0,0 +1,13 @@ +using System.Net.Sockets; + +// A Socket returned by TcpListener.AcceptSocket() is a fresh owned IDisposable the caller must +// dispose; dropping it leaks the accepted connection. The listener is a borrowed parameter, so the +// ONLY leak is `sock`. +static class AcceptSocketLeak +{ + static bool Serve(TcpListener listener) + { + var sock = listener.AcceptSocket(); // fresh owned Socket -> OWN001 (never disposed) + return sock.Connected; // used, but never disposed -> leak + } +} diff --git a/corpus/real-world/tcplistener-acceptsocket-leak/case.own b/corpus/real-world/tcplistener-acceptsocket-leak/case.own new file mode 100644 index 00000000..faec6c5b --- /dev/null +++ b/corpus/real-world/tcplistener-acceptsocket-leak/case.own @@ -0,0 +1,15 @@ +// OwnLang model of the TcpListener.AcceptSocket() accept-loop leak (owned-API tranche). The +// accepted Socket is a fresh owned IDisposable, used and never released -> OWN001. See notes.md. +module Corpus +resource Sock { + acquire accept + release dispose + kind "disposable" + emit_type "Socket" + emit_acquire "{args}.AcceptSocket()" + emit_release "{0}.Dispose()" +} +fn Serve(listener: int) { + let sock = acquire Sock(listener); // var sock = listener.AcceptSocket() + // sock used (sock.Connected); no `release sock;` -> OWN001 +} diff --git a/corpus/real-world/tcplistener-acceptsocket-leak/expected-diagnostics.txt b/corpus/real-world/tcplistener-acceptsocket-leak/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/tcplistener-acceptsocket-leak/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/tcplistener-acceptsocket-leak/notes.md b/corpus/real-world/tcplistener-acceptsocket-leak/notes.md new file mode 100644 index 00000000..05f0ed24 --- /dev/null +++ b/corpus/real-world/tcplistener-acceptsocket-leak/notes.md @@ -0,0 +1,8 @@ +# tcplistener-acceptsocket-leak + +`TcpListener.AcceptSocket()` returns a fresh **owned** `Socket` the caller must dispose; dropping it +leaks the accepted connection. Covers the `TcpListener.AcceptSocket` branch of the accept-loop +owned-API rule (sibling to `tcplistener-accept-leak`, which covers `AcceptTcpClient`). + +- **before.cs** — `var sock = listener.AcceptSocket();` used and never disposed → `OWN001`. +- **after.cs** — `using var sock = …` → clean. diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index f353ad0d..048ed900 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -2324,6 +2324,22 @@ static bool IsOwningFactory(ExpressionSyntax? e, SemanticModel model) && ImplementsSystemDataInterface(sym.ContainingType, "IDbConnection") && ImplementsSystemDataInterface(sym.ReturnType, "IDbTransaction")))) return true; + // Network "accept" owned factories — a server that accepts a connection OWNS the returned + // client/socket and must dispose it; dropping it leaks the accepted connection (the handle is + // held until finalization), a classic accept-loop server leak. INSTANCE methods, matched by the + // concrete BCL receiver type + method name (Socket / TcpListener are not subclassed in practice, + // like the File branch) with the result's IDisposable pinned, so the async variants + // (AcceptAsync / AcceptTcpClientAsync -> Task/ValueTask, not IDisposable) are excluded: + // * Socket.Accept() -> a new connected Socket the caller must dispose + // * TcpListener.AcceptSocket() -> a new connected Socket the caller must dispose + // * TcpListener.AcceptTcpClient() -> a new TcpClient the caller must dispose + if (!AnyDisposableArgument(i, model) + && ImplementsIDisposable(sym.ReturnType) + && ((sym.Name == "Accept" && sym.ContainingType is { Name: "Socket" }) + || (sym.Name is "AcceptSocket" or "AcceptTcpClient" + && sym.ContainingType is { Name: "TcpListener" })) + && IsInNamespace(sym.ContainingType, "System", "Net", "Sockets")) + return true; return false; }