Skip to content

Commit feae557

Browse files
committed
dockerfile: rule check for protocol casing in EXPOSE instruction
Signed-off-by: CrazyMax <[email protected]>
1 parent 77850e6 commit feae557

File tree

8 files changed

+216
-71
lines changed

8 files changed

+216
-71
lines changed

frontend/dockerfile/dockerfile2llb/convert.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ func dispatch(d *dispatchState, cmd command, opt dispatchOpt) error {
971971
case *instructions.HealthCheckCommand:
972972
err = dispatchHealthcheck(d, c, opt.lint)
973973
case *instructions.ExposeCommand:
974-
err = dispatchExpose(d, c, opt.shlex)
974+
err = dispatchExpose(d, c, &opt)
975975
case *instructions.UserCommand:
976976
err = dispatchUser(d, c, true)
977977
case *instructions.VolumeCommand:

frontend/dockerfile/dockerfile2llb/convert_expose.go

Lines changed: 91 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,93 +7,95 @@ import (
77
"strings"
88

99
"github.com/moby/buildkit/frontend/dockerfile/instructions"
10-
"github.com/moby/buildkit/frontend/dockerfile/shell"
10+
"github.com/moby/buildkit/frontend/dockerfile/linter"
11+
"github.com/moby/buildkit/frontend/dockerfile/parser"
1112
"github.com/pkg/errors"
1213
)
1314

14-
func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, shlex *shell.Lex) error {
15+
func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, opt *dispatchOpt) error {
1516
ports := []string{}
1617
env := getEnv(d.state)
1718
for _, p := range c.Ports {
18-
ps, err := shlex.ProcessWords(p, env)
19+
ps, err := opt.shlex.ProcessWords(p, env)
1920
if err != nil {
2021
return err
2122
}
2223
ports = append(ports, ps...)
2324
}
2425
c.Ports = ports
2526

26-
ps, err := parsePortSpecs(c.Ports)
27+
ps := newPortSpecs(
28+
withLocation(c.Location()),
29+
withLint(opt.lint),
30+
)
31+
32+
psp, err := ps.parsePorts(c.Ports)
2733
if err != nil {
2834
return err
2935
}
3036

3137
if d.image.Config.ExposedPorts == nil {
3238
d.image.Config.ExposedPorts = make(map[string]struct{})
3339
}
34-
for _, p := range ps {
40+
for _, p := range psp {
3541
d.image.Config.ExposedPorts[p] = struct{}{}
3642
}
3743

3844
return commitToHistory(&d.image, fmt.Sprintf("EXPOSE %v", ps), false, nil, d.epoch)
3945
}
4046

41-
// parsePortSpecs receives port specs in the format of [ip:]public:private/proto
42-
// and returns them as a list of "port/proto".
43-
func parsePortSpecs(ports []string) (exposedPorts []string, _ error) {
44-
for _, p := range ports {
45-
portProtos, err := parsePortSpec(p)
46-
if err != nil {
47-
return nil, err
48-
}
49-
exposedPorts = append(exposedPorts, portProtos...)
50-
}
51-
return exposedPorts, nil
47+
type portSpecs struct {
48+
location []parser.Range
49+
lint *linter.Linter
5250
}
5351

54-
// splitProtoPort splits a port(range) and protocol, formatted as "<portnum>/[<proto>]"
55-
// "<startport-endport>/[<proto>]". It returns an error if no port(range) or
56-
// an invalid proto is provided. If no protocol is provided, the default ("tcp")
57-
// protocol is returned.
58-
func splitProtoPort(rawPort string) (proto string, port string, _ error) {
59-
port, proto, _ = strings.Cut(rawPort, "/")
60-
if port == "" {
61-
return "", "", errors.New("no port specified")
52+
type portSpecsOption func(ps *portSpecs)
53+
54+
func withLocation(location []parser.Range) portSpecsOption {
55+
return func(ps *portSpecs) {
56+
ps.location = location
6257
}
63-
proto = strings.ToLower(proto)
64-
switch proto {
65-
case "":
66-
return "tcp", port, nil
67-
case "tcp", "udp", "sctp":
68-
return proto, port, nil
69-
default:
70-
return "", "", errors.New("invalid proto: " + proto)
58+
}
59+
60+
func withLint(lint *linter.Linter) portSpecsOption {
61+
return func(ps *portSpecs) {
62+
ps.lint = lint
7163
}
7264
}
7365

74-
func splitParts(rawport string) (hostIP, hostPort, containerPort string) {
75-
parts := strings.Split(rawport, ":")
66+
func newPortSpecs(opts ...portSpecsOption) *portSpecs {
67+
ps := &portSpecs{}
68+
for _, opt := range opts {
69+
opt(ps)
70+
}
71+
return ps
72+
}
7673

77-
switch len(parts) {
78-
case 1:
79-
return "", "", parts[0]
80-
case 2:
81-
return "", parts[0], parts[1]
82-
case 3:
83-
return parts[0], parts[1], parts[2]
84-
default:
85-
n := len(parts)
86-
return strings.Join(parts[:n-2], ":"), parts[n-2], parts[n-1]
74+
// parsePorts receives port specs in the format of [ip:]public:private/proto
75+
// and returns them as a list of "port/proto".
76+
func (ps *portSpecs) parsePorts(ports []string) (exposedPorts []string, _ error) {
77+
for _, p := range ports {
78+
portProtos, err := ps.parsePort(p)
79+
if err != nil {
80+
return nil, err
81+
}
82+
exposedPorts = append(exposedPorts, portProtos...)
8783
}
84+
return exposedPorts, nil
8885
}
8986

90-
// parsePortSpec parses a port specification string into a slice of "<portnum>/[<proto>]"
91-
func parsePortSpec(rawPort string) (portProto []string, _ error) {
92-
ip, hostPort, containerPort := splitParts(rawPort)
93-
proto, containerPort, err := splitProtoPort(containerPort)
87+
// parsePort parses a port specification string into a slice of "<portnum>/[<proto>]"
88+
func (ps *portSpecs) parsePort(rawPort string) (portProto []string, _ error) {
89+
ip, hostPort, containerPort := ps.splitParts(rawPort)
90+
proto, containerPort, err := ps.splitProtoPort(containerPort)
9491
if err != nil {
9592
return nil, errors.Wrapf(err, "invalid port: %q", rawPort)
9693
}
94+
if lcproto := strings.ToLower(proto); proto != lcproto && ps.lint != nil {
95+
msg := linter.RuleExposeProtoCasing.Format(rawPort)
96+
ps.lint.Run(&linter.RuleExposeProtoCasing, ps.location, msg)
97+
proto = lcproto
98+
}
9799

98100
// TODO(thaJeztah): mapping IP-addresses should not be allowed for EXPOSE; see https://github.com/moby/buildkit/issues/2173
99101
if ip != "" && ip[0] == '[' {
@@ -108,14 +110,14 @@ func parsePortSpec(rawPort string) (portProto []string, _ error) {
108110
return nil, errors.New("invalid IP address: " + ip)
109111
}
110112

111-
startPort, endPort, err := parsePortRange(containerPort)
113+
startPort, endPort, err := ps.parsePortRange(containerPort)
112114
if err != nil {
113115
return nil, errors.New("invalid containerPort: " + containerPort)
114116
}
115117

116118
// TODO(thaJeztah): mapping host-ports should not be allowed for EXPOSE; see https://github.com/moby/buildkit/issues/2173
117119
if hostPort != "" {
118-
startHostPort, endHostPort, err := parsePortRange(hostPort)
120+
startHostPort, endHostPort, err := ps.parsePortRange(hostPort)
119121
if err != nil {
120122
return nil, errors.New("invalid hostPort: " + hostPort)
121123
}
@@ -139,21 +141,21 @@ func parsePortSpec(rawPort string) (portProto []string, _ error) {
139141
}
140142

141143
// parsePortRange parses and validates the specified string as a port range (e.g., "8000-9000").
142-
func parsePortRange(ports string) (startPort, endPort int, _ error) {
144+
func (ps *portSpecs) parsePortRange(ports string) (startPort, endPort int, _ error) {
143145
if ports == "" {
144146
return 0, 0, errors.New("empty string specified for ports")
145147
}
146148
start, end, ok := strings.Cut(ports, "-")
147149

148-
startPort, err := parsePortNumber(start)
150+
startPort, err := ps.parsePortNumber(start)
149151
if err != nil {
150152
return 0, 0, errors.Wrapf(err, "invalid start port '%s'", start)
151153
}
152154
if !ok || start == end {
153155
return startPort, startPort, nil
154156
}
155157

156-
endPort, err = parsePortNumber(end)
158+
endPort, err = ps.parsePortNumber(end)
157159
if err != nil {
158160
return 0, 0, errors.Wrapf(err, "invalid end port '%s'", end)
159161
}
@@ -165,7 +167,7 @@ func parsePortRange(ports string) (startPort, endPort int, _ error) {
165167

166168
// parsePortNumber parses rawPort into an int, unwrapping strconv errors
167169
// and returning a single "out of range" error for any value outside 0–65535.
168-
func parsePortNumber(rawPort string) (int, error) {
170+
func (ps *portSpecs) parsePortNumber(rawPort string) (int, error) {
169171
if rawPort == "" {
170172
return 0, errors.New("value is empty")
171173
}
@@ -183,3 +185,38 @@ func parsePortNumber(rawPort string) (int, error) {
183185

184186
return int(port), nil
185187
}
188+
189+
// splitProtoPort splits a port(range) and protocol, formatted as "<portnum>/[<proto>]"
190+
// "<startport-endport>/[<proto>]". It returns an error if no port(range) or
191+
// an invalid proto is provided. If no protocol is provided, the default ("tcp")
192+
// protocol is returned.
193+
func (ps *portSpecs) splitProtoPort(rawPort string) (proto string, port string, _ error) {
194+
port, proto, _ = strings.Cut(rawPort, "/")
195+
if port == "" {
196+
return "", "", errors.New("no port specified")
197+
}
198+
switch strings.ToLower(proto) {
199+
case "":
200+
return "tcp", port, nil
201+
case "tcp", "udp", "sctp":
202+
return proto, port, nil
203+
default:
204+
return "", "", errors.New("invalid proto: " + proto)
205+
}
206+
}
207+
208+
func (ps *portSpecs) splitParts(rawport string) (hostIP, hostPort, containerPort string) {
209+
parts := strings.Split(rawport, ":")
210+
211+
switch len(parts) {
212+
case 1:
213+
return "", "", parts[0]
214+
case 2:
215+
return "", parts[0], parts[1]
216+
case 3:
217+
return parts[0], parts[1], parts[2]
218+
default:
219+
n := len(parts)
220+
return strings.Join(parts[:n-2], ":"), parts[n-2], parts[n-1]
221+
}
222+
}

frontend/dockerfile/dockerfile2llb/convert_expose_test.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"github.com/stretchr/testify/require"
88
)
99

10-
func TestParsePortSpecEmptyContainerPort(t *testing.T) {
10+
var ps = newPortSpecs()
11+
12+
func TestParsePortEmptyContainerPort(t *testing.T) {
1113
tests := []struct {
1214
name string
1315
spec string
@@ -31,7 +33,7 @@ func TestParsePortSpecEmptyContainerPort(t *testing.T) {
3133
}
3234
for _, tc := range tests {
3335
t.Run(tc.name, func(t *testing.T) {
34-
_, err := parsePortSpec(tc.spec)
36+
_, err := ps.parsePort(tc.spec)
3537
if tc.expError != "" {
3638
assert.EqualError(t, err, tc.expError)
3739
} else {
@@ -41,13 +43,13 @@ func TestParsePortSpecEmptyContainerPort(t *testing.T) {
4143
}
4244
}
4345

44-
func TestParsePortSpecFull(t *testing.T) {
45-
exposedPorts, err := parsePortSpec("0.0.0.0:1234-1235:3333-3334/tcp")
46+
func TestParsePortFull(t *testing.T) {
47+
exposedPorts, err := ps.parsePort("0.0.0.0:1234-1235:3333-3334/tcp")
4648
require.NoError(t, err)
4749
assert.Equal(t, []string{"3333/tcp", "3334/tcp"}, exposedPorts)
4850
}
4951

50-
func TestPartPortSpecIPV6(t *testing.T) {
52+
func TestPartPortIPV6(t *testing.T) {
5153
type test struct {
5254
name string
5355
spec string
@@ -82,43 +84,43 @@ func TestPartPortSpecIPV6(t *testing.T) {
8284
}
8385
for _, c := range cases {
8486
t.Run(c.name, func(t *testing.T) {
85-
exposedPorts, err := parsePortSpec(c.spec)
87+
exposedPorts, err := ps.parsePort(c.spec)
8688
require.NoError(t, err)
8789
assert.Equal(t, c.expected, exposedPorts)
8890
})
8991
}
9092
}
9193

92-
func TestParsePortSpecs(t *testing.T) {
93-
exposedPorts, err := parsePortSpecs([]string{"1234/tcp", "2345/udp", "3456/sctp"})
94+
func TestParsePorts(t *testing.T) {
95+
exposedPorts, err := ps.parsePorts([]string{"1234/tcp", "2345/udp", "3456/sctp"})
9496
require.NoError(t, err)
9597
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
9698

97-
exposedPorts, err = parsePortSpecs([]string{"1234:1234/tcp", "2345:2345/udp", "3456:3456/sctp"})
99+
exposedPorts, err = ps.parsePorts([]string{"1234:1234/tcp", "2345:2345/udp", "3456:3456/sctp"})
98100
require.NoError(t, err)
99101
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
100102

101-
exposedPorts, err = parsePortSpecs([]string{"0.0.0.0:1234:1234/tcp", "0.0.0.0:2345:2345/udp", "0.0.0.0:3456:3456/sctp"})
103+
exposedPorts, err = ps.parsePorts([]string{"0.0.0.0:1234:1234/tcp", "0.0.0.0:2345:2345/udp", "0.0.0.0:3456:3456/sctp"})
102104
require.NoError(t, err)
103105
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
104106

105-
_, err = parsePortSpecs([]string{"localhost:1234:1234/tcp"})
107+
_, err = ps.parsePorts([]string{"localhost:1234:1234/tcp"})
106108
assert.Error(t, err, "Received no error while trying to parse a hostname instead of ip")
107109
}
108110

109-
func TestParsePortSpecsWithRange(t *testing.T) {
110-
exposedPorts, err := parsePortSpecs([]string{"1234-1236/tcp", "2345-2347/udp", "3456-3458/sctp"})
111+
func TestParsePortsWithRange(t *testing.T) {
112+
exposedPorts, err := ps.parsePorts([]string{"1234-1236/tcp", "2345-2347/udp", "3456-3458/sctp"})
111113
require.NoError(t, err)
112114
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
113115

114-
exposedPorts, err = parsePortSpecs([]string{"1234-1236:1234-1236/tcp", "2345-2347:2345-2347/udp", "3456-3458:3456-3458/sctp"})
116+
exposedPorts, err = ps.parsePorts([]string{"1234-1236:1234-1236/tcp", "2345-2347:2345-2347/udp", "3456-3458:3456-3458/sctp"})
115117
require.NoError(t, err)
116118
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
117119

118-
exposedPorts, err = parsePortSpecs([]string{"0.0.0.0:1234-1236:1234-1236/tcp", "0.0.0.0:2345-2347:2345-2347/udp", "0.0.0.0:3456-3458:3456-3458/sctp"})
120+
exposedPorts, err = ps.parsePorts([]string{"0.0.0.0:1234-1236:1234-1236/tcp", "0.0.0.0:2345-2347:2345-2347/udp", "0.0.0.0:3456-3458:3456-3458/sctp"})
119121
require.NoError(t, err)
120122
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
121123

122-
_, err = parsePortSpecs([]string{"localhost:1234-1236:1234-1236/tcp"})
124+
_, err = ps.parsePorts([]string{"localhost:1234-1236:1234-1236/tcp"})
123125
assert.Error(t, err, "Received no error while trying to parse a hostname instead of ip")
124126
}

frontend/dockerfile/dockerfile_lint_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ var lintTests = integration.TestFuncs(
4949
testFromPlatformFlagConstDisallowed,
5050
testCopyIgnoredFiles,
5151
testDefinitionDescription,
52+
testExposeProtoCasing,
5253
)
5354

5455
func testDefinitionDescription(t *testing.T, sb integration.Sandbox) {
@@ -1435,6 +1436,34 @@ FROM --platform=linux/amd64 scratch AS linux
14351436
})
14361437
}
14371438

1439+
func testExposeProtoCasing(t *testing.T, sb integration.Sandbox) {
1440+
dockerfile := []byte(`
1441+
FROM busybox
1442+
EXPOSE 80/TcP 8080/TCP 8080/udp
1443+
`)
1444+
checkLinterWarnings(t, sb, &lintTestParams{
1445+
Dockerfile: dockerfile,
1446+
Warnings: []expectedLintWarning{
1447+
{
1448+
RuleName: "ExposeProtoCasing",
1449+
Description: "Protocol in EXPOSE instruction should be lowercase",
1450+
URL: "https://docs.docker.com/go/dockerfile/rule/expose-proto-casing/",
1451+
Detail: "Defined protocol '80/TcP' in EXPOSE instruction should be lowercase",
1452+
Level: 1,
1453+
Line: 3,
1454+
},
1455+
{
1456+
RuleName: "ExposeProtoCasing",
1457+
Description: "Protocol in EXPOSE instruction should be lowercase",
1458+
URL: "https://docs.docker.com/go/dockerfile/rule/expose-proto-casing/",
1459+
Detail: "Defined protocol '8080/TCP' in EXPOSE instruction should be lowercase",
1460+
Level: 1,
1461+
Line: 3,
1462+
},
1463+
},
1464+
})
1465+
}
1466+
14381467
func checkUnmarshal(t *testing.T, sb integration.Sandbox, lintTest *lintTestParams) {
14391468
destDir, err := os.MkdirTemp("", "buildkit")
14401469
require.NoError(t, err)

frontend/dockerfile/docs/rules/_index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,5 +107,9 @@ To learn more about how to use build checks, see
107107
<td><a href="./invalid-definition-description/">InvalidDefinitionDescription (experimental)</a></td>
108108
<td>Comment for build stage or argument should follow the format: `# <arg/stage name> <description>`. If this is not intended to be a description comment, add an empty line or comment between the instruction and the comment.</td>
109109
</tr>
110+
<tr>
111+
<td><a href="./expose-proto-casing/">ExposeProtoCasing</a></td>
112+
<td>Protocol in EXPOSE instruction should be lowercase</td>
113+
</tr>
110114
</tbody>
111115
</table>

0 commit comments

Comments
 (0)