From ddcc3a15aaee07c41bf065e4f42b5e5009d6e720 Mon Sep 17 00:00:00 2001 From: Rob Murray Date: Wed, 22 Nov 2023 11:34:18 +0000 Subject: [PATCH] Permit '=' separator and '[ipv6]' in 'extra_hosts'. Fixes docker/cli#4648 Align the format of 'extra_hosts' strings with '--add-hosts' options in the docker CLI and buildx - by permitting 'host=ip' in addition to 'host:ip', and allowing square brackets around the address. For example: extra_hosts: - "my-host1:127.0.0.1" - "my-host2:::1" - "my-host3=::1" - "my-host4=[::1]" Signed-off-by: Rob Murray --- types/hostList.go | 15 +++- types/hostList_test.go | 156 +++++++++++++++++++++++++++++++++++++++++ types/mapping.go | 19 +++-- 3 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 types/hostList_test.go diff --git a/types/hostList.go b/types/hostList.go index 007f9ea19..bd6bf2bc2 100644 --- a/types/hostList.go +++ b/types/hostList.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" ) // HostsList is a list of colon-separated host-ip mappings @@ -58,9 +59,21 @@ func (h *HostsList) DecodeMapstructure(value interface{}) error { } *h = list case []interface{}: - *h = decodeMapping(v, ":") + *h = decodeMapping(v, "=", ":") default: return fmt.Errorf("unexpected value type %T for mapping", value) } + for host, ip := range *h { + // Check that there is a hostname and that it doesn't contain either + // of the allowed separators, to generate a clearer error than the + // engine would do if it splits the string differently. + if host == "" || strings.ContainsAny(host, ":=") { + return fmt.Errorf("bad host name '%s'", host) + } + // Remove brackets from IP addresses (for example "[::1]" -> "::1"). + if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' { + (*h)[host] = ip[1 : len(ip)-1] + } + } return nil } diff --git a/types/hostList_test.go b/types/hostList_test.go new file mode 100644 index 000000000..26defcb76 --- /dev/null +++ b/types/hostList_test.go @@ -0,0 +1,156 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import ( + "sort" + "strings" + "testing" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestHostsList(t *testing.T) { + testCases := []struct { + doc string + input map[string]any + expectedError string + expectedOut string + }{ + { + doc: "IPv4", + input: map[string]any{"myhost": "192.168.0.1"}, + expectedOut: "myhost:192.168.0.1", + }, + { + doc: "Weird but permitted, IPv4 with brackets", + input: map[string]any{"myhost": "[192.168.0.1]"}, + expectedOut: "myhost:192.168.0.1", + }, + { + doc: "Host and domain", + input: map[string]any{"host.invalid": "10.0.2.1"}, + expectedOut: "host.invalid:10.0.2.1", + }, + { + doc: "IPv6", + input: map[string]any{"anipv6host": "2003:ab34:e::1"}, + expectedOut: "anipv6host:2003:ab34:e::1", + }, + { + doc: "IPv6, brackets", + input: map[string]any{"anipv6host": "[2003:ab34:e::1]"}, + expectedOut: "anipv6host:2003:ab34:e::1", + }, + { + doc: "IPv6 localhost", + input: map[string]any{"ipv6local": "::1"}, + expectedOut: "ipv6local:::1", + }, + { + doc: "IPv6 localhost, brackets", + input: map[string]any{"ipv6local": "[::1]"}, + expectedOut: "ipv6local:::1", + }, + { + doc: "host-gateway special case", + input: map[string]any{"host.docker.internal": "host-gateway"}, + expectedOut: "host.docker.internal:host-gateway", + }, + { + doc: "multiple inputs", + input: map[string]any{ + "myhost": "192.168.0.1", + "anipv6host": "[2003:ab34:e::1]", + "host.docker.internal": "host-gateway", + }, + expectedOut: "anipv6host:2003:ab34:e::1 host.docker.internal:host-gateway myhost:192.168.0.1", + }, + { + // This won't work, but address validation is left to the engine. + doc: "no ip", + input: map[string]any{"myhost": nil}, + expectedOut: "myhost:", + }, + { + doc: "bad host, colon", + input: map[string]any{":": "::1"}, + expectedError: "bad host name", + }, + { + doc: "bad host, eq", + input: map[string]any{"=": "::1"}, + expectedError: "bad host name", + }, + } + + inputAsList := func(input map[string]any, sep string) []any { + result := make([]any, 0, len(input)) + for host, ip := range input { + if ip == nil { + result = append(result, host+sep) + } else { + result = append(result, host+sep+ip.(string)) + } + } + return result + } + + for _, tc := range testCases { + // Decode the input map, check the output is as-expected. + var hlFromMap HostsList + t.Run(tc.doc+"_map", func(t *testing.T) { + err := hlFromMap.DecodeMapstructure(tc.input) + if tc.expectedError == "" { + assert.NilError(t, err) + actualOut := hlFromMap.AsList() + sort.Strings(actualOut) + sortedActualStr := strings.Join(actualOut, " ") + assert.Check(t, is.Equal(sortedActualStr, tc.expectedOut)) + } else { + assert.ErrorContains(t, err, tc.expectedError) + } + }) + + // Convert the input into a ':' separated list, check that the result is the same + // as for the map-input. + t.Run(tc.doc+"_colon_sep", func(t *testing.T) { + var hl HostsList + err := hl.DecodeMapstructure(inputAsList(tc.input, ":")) + if tc.expectedError == "" { + assert.NilError(t, err) + assert.DeepEqual(t, hl, hlFromMap) + } else { + assert.ErrorContains(t, err, tc.expectedError) + } + }) + + // Convert the input into a ':' separated list, check that the result is the same + // as for the map-input. + t.Run(tc.doc+"_eq_sep", func(t *testing.T) { + var hl HostsList + err := hl.DecodeMapstructure(inputAsList(tc.input, "=")) + if tc.expectedError == "" { + assert.NilError(t, err) + assert.DeepEqual(t, hl, hlFromMap) + } else { + assert.ErrorContains(t, err, tc.expectedError) + } + }) + } +} diff --git a/types/mapping.go b/types/mapping.go index ea8e137fe..9d70730bb 100644 --- a/types/mapping.go +++ b/types/mapping.go @@ -195,14 +195,23 @@ func (m *Mapping) DecodeMapstructure(value interface{}) error { return nil } -func decodeMapping(v []interface{}, sep string) map[string]string { +// Generate a mapping by splitting strings at any of []sep, which will be tried +// in-order for each input string. (For example, to allow the preferred 'host=ip' +// in 'extra_hosts', as well as 'host:ip' for backwards compatibility.) +func decodeMapping(v []interface{}, seps ...string) map[string]string { mapping := make(Mapping, len(v)) for _, s := range v { - k, e, ok := strings.Cut(fmt.Sprint(s), sep) - if !ok { - e = "" + for i, sep := range seps { + k, e, ok := strings.Cut(fmt.Sprint(s), sep) + if ok { + // Mapping found with this separator, stop here. + mapping[k] = e + break + } else if i == len(seps)-1 { + // No more separators to try, map to empty string. + mapping[k] = "" + } } - mapping[k] = e } return mapping }