Skip to content

Commit 7c7f353

Browse files
adonovangopherbot
authored andcommitted
gopls/internal/analysis/hostport: report net.Dial("%s:%d") addresses
This change defines an analyzer that reports calls to net.Dial, net.DialTimeout, or net.Dialer.Dial with an address produced by a direct call to fmt.Sprintf, or via an intermediate local variable declared using the form: addr := fmt.Sprintf("%s:%d", host, port) ... net.Dial("tcp", addr) In other words, it uses the more precise approach suggested in dominikh/go-tools#358, not the blunter instrument of golang/go#28308. Formatting addresses this way doesn't work with IPv6. The diagnostic carries a fix to use net.JoinHostPort instead. The analyzer turns up a fairly small number of diagnostics across the corpus; however it is precise and cheap to run (since it requires a direct import of net). + test, relnote, doc We plan to add this to cmd/vet after go1.24 is released. Updates golang/go#28308 Updates dominikh/go-tools#358 Change-Id: I72e27253b75ed4702762a65c1b069e7920103bb7 Reviewed-on: https://go-review.googlesource.com/c/tools/+/554495 Auto-Submit: Alan Donovan <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Robert Findley <[email protected]>
1 parent 8179c75 commit 7c7f353

File tree

9 files changed

+350
-0
lines changed

9 files changed

+350
-0
lines changed

gopls/doc/analyzers.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,31 @@ Default: on.
290290

291291
Package documentation: [framepointer](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/framepointer)
292292

293+
<a id='hostport'></a>
294+
## `hostport`: check format of addresses passed to net.Dial
295+
296+
297+
This analyzer flags code that produce network address strings using
298+
fmt.Sprintf, as in this example:
299+
300+
addr := fmt.Sprintf("%s:%d", host, 12345) // "will not work with IPv6"
301+
...
302+
conn, err := net.Dial("tcp", addr) // "when passed to dial here"
303+
304+
The analyzer suggests a fix to use the correct approach, a call to
305+
net.JoinHostPort:
306+
307+
addr := net.JoinHostPort(host, "12345")
308+
...
309+
conn, err := net.Dial("tcp", addr)
310+
311+
A similar diagnostic and fix are produced for a format string of "%s:%s".
312+
313+
314+
Default: on.
315+
316+
Package documentation: [hostport](https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/hostport)
317+
293318
<a id='httpresponse'></a>
294319
## `httpresponse`: check for mistakes using HTTP responses
295320

gopls/doc/release/v0.18.0.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ functions and methods are candidates.
4040
(For a more precise analysis that may report unused exported
4141
functions too, use the `golang.org/x/tools/cmd/deadcode` command.)
4242

43+
## New `hostport` analyzer
44+
45+
With the growing use of IPv6, forming a "host:port" string using
46+
`fmt.Sprintf("%s:%d")` is no longer appropriate because host names may
47+
contain colons. Gopls now reports places where a string constructed in
48+
this fashion (or with `%s` for the port) is passed to `net.Dial` or a
49+
related function, and offers a fix to use `net.JoinHostPort`
50+
instead.
51+
4352
## "Implementations" supports generics
4453

4554
At long last, the "Go to Implementations" feature now fully supports
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package hostport defines an analyzer for calls to net.Dial with
6+
// addresses of the form "%s:%d" or "%s:%s", which work only with IPv4.
7+
package hostport
8+
9+
import (
10+
"fmt"
11+
"go/ast"
12+
"go/constant"
13+
"go/types"
14+
15+
"golang.org/x/tools/go/analysis"
16+
"golang.org/x/tools/go/analysis/passes/inspect"
17+
"golang.org/x/tools/go/ast/inspector"
18+
"golang.org/x/tools/go/types/typeutil"
19+
"golang.org/x/tools/gopls/internal/util/safetoken"
20+
"golang.org/x/tools/internal/analysisinternal"
21+
"golang.org/x/tools/internal/astutil/cursor"
22+
)
23+
24+
const Doc = `check format of addresses passed to net.Dial
25+
26+
This analyzer flags code that produce network address strings using
27+
fmt.Sprintf, as in this example:
28+
29+
addr := fmt.Sprintf("%s:%d", host, 12345) // "will not work with IPv6"
30+
...
31+
conn, err := net.Dial("tcp", addr) // "when passed to dial here"
32+
33+
The analyzer suggests a fix to use the correct approach, a call to
34+
net.JoinHostPort:
35+
36+
addr := net.JoinHostPort(host, "12345")
37+
...
38+
conn, err := net.Dial("tcp", addr)
39+
40+
A similar diagnostic and fix are produced for a format string of "%s:%s".
41+
`
42+
43+
var Analyzer = &analysis.Analyzer{
44+
Name: "hostport",
45+
Doc: Doc,
46+
URL: "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/hostport",
47+
Requires: []*analysis.Analyzer{inspect.Analyzer},
48+
Run: run,
49+
}
50+
51+
func run(pass *analysis.Pass) (any, error) {
52+
// Fast path: if the package doesn't import net and fmt, skip
53+
// the traversal.
54+
if !analysisinternal.Imports(pass.Pkg, "net") ||
55+
!analysisinternal.Imports(pass.Pkg, "fmt") {
56+
return nil, nil
57+
}
58+
59+
info := pass.TypesInfo
60+
61+
// checkAddr reports a diagnostic (and returns true) if e
62+
// is a call of the form fmt.Sprintf("%d:%d", ...).
63+
// The diagnostic includes a fix.
64+
//
65+
// dialCall is non-nil if the Dial call is non-local
66+
// but within the same file.
67+
checkAddr := func(e ast.Expr, dialCall *ast.CallExpr) {
68+
if call, ok := e.(*ast.CallExpr); ok {
69+
obj := typeutil.Callee(info, call)
70+
if analysisinternal.IsFunctionNamed(obj, "fmt", "Sprintf") {
71+
// Examine format string.
72+
formatArg := call.Args[0]
73+
if tv := info.Types[formatArg]; tv.Value != nil {
74+
numericPort := false
75+
format := constant.StringVal(tv.Value)
76+
switch format {
77+
case "%s:%d":
78+
// Have: fmt.Sprintf("%s:%d", host, port)
79+
numericPort = true
80+
81+
case "%s:%s":
82+
// Have: fmt.Sprintf("%s:%s", host, portStr)
83+
// Keep port string as is.
84+
85+
default:
86+
return
87+
}
88+
89+
// Use granular edits to preserve original formatting.
90+
edits := []analysis.TextEdit{
91+
{
92+
// Replace fmt.Sprintf with net.JoinHostPort.
93+
Pos: call.Fun.Pos(),
94+
End: call.Fun.End(),
95+
NewText: []byte("net.JoinHostPort"),
96+
},
97+
{
98+
// Delete format string.
99+
Pos: formatArg.Pos(),
100+
End: call.Args[1].Pos(),
101+
},
102+
}
103+
104+
// Turn numeric port into a string.
105+
if numericPort {
106+
// port => fmt.Sprintf("%d", port)
107+
// 123 => "123"
108+
port := call.Args[2]
109+
newPort := fmt.Sprintf(`fmt.Sprintf("%%d", %s)`, port)
110+
if port := info.Types[port].Value; port != nil {
111+
if i, ok := constant.Int64Val(port); ok {
112+
newPort = fmt.Sprintf(`"%d"`, i) // numeric constant
113+
}
114+
}
115+
116+
edits = append(edits, analysis.TextEdit{
117+
Pos: port.Pos(),
118+
End: port.End(),
119+
NewText: []byte(newPort),
120+
})
121+
}
122+
123+
// Refer to Dial call, if not adjacent.
124+
suffix := ""
125+
if dialCall != nil {
126+
suffix = fmt.Sprintf(" (passed to net.Dial at L%d)",
127+
safetoken.StartPosition(pass.Fset, dialCall.Pos()).Line)
128+
}
129+
130+
pass.Report(analysis.Diagnostic{
131+
// Highlight the format string.
132+
Pos: formatArg.Pos(),
133+
End: formatArg.End(),
134+
Message: fmt.Sprintf("address format %q does not work with IPv6%s", format, suffix),
135+
SuggestedFixes: []analysis.SuggestedFix{{
136+
Message: "Replace fmt.Sprintf with net.JoinHostPort",
137+
TextEdits: edits,
138+
}},
139+
})
140+
}
141+
}
142+
}
143+
}
144+
145+
// Check address argument of each call to net.Dial et al.
146+
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
147+
for curCall := range cursor.Root(inspect).Preorder((*ast.CallExpr)(nil)) {
148+
call := curCall.Node().(*ast.CallExpr)
149+
150+
obj := typeutil.Callee(info, call)
151+
if analysisinternal.IsFunctionNamed(obj, "net", "Dial", "DialTimeout") ||
152+
analysisinternal.IsMethodNamed(obj, "net", "Dialer", "Dial") {
153+
154+
switch address := call.Args[1].(type) {
155+
case *ast.CallExpr:
156+
// net.Dial("tcp", fmt.Sprintf("%s:%d", ...))
157+
checkAddr(address, nil)
158+
159+
case *ast.Ident:
160+
// addr := fmt.Sprintf("%s:%d", ...)
161+
// ...
162+
// net.Dial("tcp", addr)
163+
164+
// Search for decl of addrVar within common ancestor of addrVar and Dial call.
165+
if addrVar, ok := info.Uses[address].(*types.Var); ok {
166+
pos := addrVar.Pos()
167+
// TODO(adonovan): use Cursor.Ancestors iterator when available.
168+
for _, curAncestor := range curCall.Stack(nil) {
169+
if curIdent, ok := curAncestor.FindPos(pos, pos); ok {
170+
// curIdent is the declaring ast.Ident of addr.
171+
switch parent := curIdent.Parent().Node().(type) {
172+
case *ast.AssignStmt:
173+
if len(parent.Rhs) == 1 {
174+
// Have: addr := fmt.Sprintf("%s:%d", ...)
175+
checkAddr(parent.Rhs[0], call)
176+
}
177+
178+
case *ast.ValueSpec:
179+
if len(parent.Values) == 1 {
180+
// Have: var addr = fmt.Sprintf("%s:%d", ...)
181+
checkAddr(parent.Values[0], call)
182+
}
183+
}
184+
break
185+
}
186+
}
187+
}
188+
}
189+
}
190+
}
191+
return nil, nil
192+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package hostport_test
6+
7+
import (
8+
"testing"
9+
10+
"golang.org/x/tools/go/analysis/analysistest"
11+
"golang.org/x/tools/gopls/internal/analysis/hostport"
12+
)
13+
14+
func Test(t *testing.T) {
15+
testdata := analysistest.TestData()
16+
analysistest.RunWithSuggestedFixes(t, testdata, hostport.Analyzer, "a")
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build ignore
6+
7+
package main
8+
9+
import (
10+
"golang.org/x/tools/go/analysis/singlechecker"
11+
"golang.org/x/tools/gopls/internal/analysis/hostport"
12+
)
13+
14+
func main() { singlechecker.Main(hostport.Analyzer) }
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package a
2+
3+
import (
4+
"fmt"
5+
"net"
6+
)
7+
8+
func direct(host string, port int, portStr string) {
9+
// Dial, directly called with result of Sprintf.
10+
net.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) // want `address format "%s:%d" does not work with IPv6`
11+
12+
net.Dial("tcp", fmt.Sprintf("%s:%s", host, portStr)) // want `address format "%s:%s" does not work with IPv6`
13+
}
14+
15+
// port is a constant:
16+
var addr4 = fmt.Sprintf("%s:%d", "localhost", 123) // want `address format "%s:%d" does not work with IPv6 \(passed to net.Dial at L39\)`
17+
18+
func indirect(host string, port int) {
19+
// Dial, addr is immediately preceding.
20+
{
21+
addr1 := fmt.Sprintf("%s:%d", host, port) // want `address format "%s:%d" does not work with IPv6.*at L22`
22+
net.Dial("tcp", addr1)
23+
}
24+
25+
// DialTimeout, addr is in ancestor block.
26+
addr2 := fmt.Sprintf("%s:%d", host, port) // want `address format "%s:%d" does not work with IPv6.*at L28`
27+
{
28+
net.DialTimeout("tcp", addr2, 0)
29+
}
30+
31+
// Dialer.Dial, addr is declared with var.
32+
var dialer net.Dialer
33+
{
34+
var addr3 = fmt.Sprintf("%s:%d", host, port) // want `address format "%s:%d" does not work with IPv6.*at L35`
35+
dialer.Dial("tcp", addr3)
36+
}
37+
38+
// Dialer.Dial again, addr is declared at package level.
39+
dialer.Dial("tcp", addr4)
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package a
2+
3+
import (
4+
"fmt"
5+
"net"
6+
)
7+
8+
func direct(host string, port int, portStr string) {
9+
// Dial, directly called with result of Sprintf.
10+
net.Dial("tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port))) // want `address format "%s:%d" does not work with IPv6`
11+
12+
net.Dial("tcp", net.JoinHostPort(host, portStr)) // want `address format "%s:%s" does not work with IPv6`
13+
}
14+
15+
// port is a constant:
16+
var addr4 = net.JoinHostPort("localhost", "123") // want `address format "%s:%d" does not work with IPv6 \(passed to net.Dial at L39\)`
17+
18+
func indirect(host string, port int) {
19+
// Dial, addr is immediately preceding.
20+
{
21+
addr1 := net.JoinHostPort(host, fmt.Sprintf("%d", port)) // want `address format "%s:%d" does not work with IPv6.*at L22`
22+
net.Dial("tcp", addr1)
23+
}
24+
25+
// DialTimeout, addr is in ancestor block.
26+
addr2 := net.JoinHostPort(host, fmt.Sprintf("%d", port)) // want `address format "%s:%d" does not work with IPv6.*at L28`
27+
{
28+
net.DialTimeout("tcp", addr2, 0)
29+
}
30+
31+
// Dialer.Dial, addr is declared with var.
32+
var dialer net.Dialer
33+
{
34+
var addr3 = net.JoinHostPort(host, fmt.Sprintf("%d", port)) // want `address format "%s:%d" does not work with IPv6.*at L35`
35+
dialer.Dial("tcp", addr3)
36+
}
37+
38+
// Dialer.Dial again, addr is declared at package level.
39+
dialer.Dial("tcp", addr4)
40+
}

gopls/internal/doc/api.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,11 @@
440440
"Doc": "report assembly that clobbers the frame pointer before saving it",
441441
"Default": "true"
442442
},
443+
{
444+
"Name": "\"hostport\"",
445+
"Doc": "check format of addresses passed to net.Dial\n\nThis analyzer flags code that produce network address strings using\nfmt.Sprintf, as in this example:\n\n addr := fmt.Sprintf(\"%s:%d\", host, 12345) // \"will not work with IPv6\"\n ...\n conn, err := net.Dial(\"tcp\", addr) // \"when passed to dial here\"\n\nThe analyzer suggests a fix to use the correct approach, a call to\nnet.JoinHostPort:\n\n addr := net.JoinHostPort(host, \"12345\")\n ...\n conn, err := net.Dial(\"tcp\", addr)\n\nA similar diagnostic and fix are produced for a format string of \"%s:%s\".\n",
446+
"Default": "true"
447+
},
443448
{
444449
"Name": "\"httpresponse\"",
445450
"Doc": "check for mistakes using HTTP responses\n\nA common mistake when using the net/http package is to defer a function\ncall to close the http.Response Body before checking the error that\ndetermines whether the response is valid:\n\n\tresp, err := http.Head(url)\n\tdefer resp.Body.Close()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\t// (defer statement belongs here)\n\nThis checker helps uncover latent nil dereference bugs by reporting a\ndiagnostic for such mistakes.",
@@ -1060,6 +1065,12 @@
10601065
"URL": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/framepointer",
10611066
"Default": true
10621067
},
1068+
{
1069+
"Name": "hostport",
1070+
"Doc": "check format of addresses passed to net.Dial\n\nThis analyzer flags code that produce network address strings using\nfmt.Sprintf, as in this example:\n\n addr := fmt.Sprintf(\"%s:%d\", host, 12345) // \"will not work with IPv6\"\n ...\n conn, err := net.Dial(\"tcp\", addr) // \"when passed to dial here\"\n\nThe analyzer suggests a fix to use the correct approach, a call to\nnet.JoinHostPort:\n\n addr := net.JoinHostPort(host, \"12345\")\n ...\n conn, err := net.Dial(\"tcp\", addr)\n\nA similar diagnostic and fix are produced for a format string of \"%s:%s\".\n",
1071+
"URL": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/hostport",
1072+
"Default": true
1073+
},
10631074
{
10641075
"Name": "httpresponse",
10651076
"Doc": "check for mistakes using HTTP responses\n\nA common mistake when using the net/http package is to defer a function\ncall to close the http.Response Body before checking the error that\ndetermines whether the response is valid:\n\n\tresp, err := http.Head(url)\n\tdefer resp.Body.Close()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\t// (defer statement belongs here)\n\nThis checker helps uncover latent nil dereference bugs by reporting a\ndiagnostic for such mistakes.",

gopls/internal/settings/analysis.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import (
4949
"golang.org/x/tools/gopls/internal/analysis/deprecated"
5050
"golang.org/x/tools/gopls/internal/analysis/embeddirective"
5151
"golang.org/x/tools/gopls/internal/analysis/fillreturns"
52+
"golang.org/x/tools/gopls/internal/analysis/hostport"
5253
"golang.org/x/tools/gopls/internal/analysis/infertypeargs"
5354
"golang.org/x/tools/gopls/internal/analysis/modernize"
5455
"golang.org/x/tools/gopls/internal/analysis/nonewvars"
@@ -158,6 +159,7 @@ func init() {
158159
{analyzer: sortslice.Analyzer, enabled: true},
159160
{analyzer: embeddirective.Analyzer, enabled: true},
160161
{analyzer: waitgroup.Analyzer, enabled: true}, // to appear in cmd/[email protected]
162+
{analyzer: hostport.Analyzer, enabled: true}, // to appear in cmd/[email protected]
161163
{analyzer: modernize.Analyzer, enabled: true, severity: protocol.SeverityInformation},
162164

163165
// disabled due to high false positives

0 commit comments

Comments
 (0)