fix(host,executor): compare origins in one canonical form with IPv6 hosts bracketed and accept a literal U+FFFD in action paths
- commit
- e509beab9805067edd041715e0d837ca87e235b6
- author
- brice · · 2026-10-14 03:14 UTC
- parent
- 6cc6c69
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +221 -36
M internal/executor/actions_download.go +14 -15
| @@ -10,14 +10,14 @@ import ( | |||
| 10 | 10 | "io" | |
| 11 | 11 | "math/rand/v2" | |
| 12 | 12 | "net/http" | |
| 13 | - | "net/url" | |
| 14 | 13 | "os" | |
| 15 | 14 | "path/filepath" | |
| 16 | 15 | "slices" | |
| 17 | - | "strings" | |
| 18 | 16 | "time" | |
| 19 | 17 | ||
| 20 | 18 | "github.com/google/uuid" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760). | |
| @@ -44,18 +44,17 @@ type errAccessDenied struct{ url string } | |||
| 44 | 44 | ||
| 45 | 45 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | |
| 46 | 46 | ||
| 47 | - | // originOf is scheme://host[:port] of an absolute http(s) URL, lower case and | |
| 48 | - | // without the default port, "" for anything else. | |
| 49 | - | func originOf(raw string) string { | |
| 50 | - | u, err := url.Parse(raw) | |
| 51 | - | if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { | |
| 52 | - | return "" | |
| 53 | - | } | |
| 54 | - | host, port := strings.ToLower(u.Hostname()), u.Port() | |
| 55 | - | if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { | |
| 56 | - | return u.Scheme + "://" + host | |
| 57 | - | } | |
| 58 | - | return u.Scheme + "://" + host + ":" + port | |
| 47 | + | // originOf is the canonical origin of a URL (urlorigin.Canonical): the one the | |
| 48 | + | // host and the executor both compare, scheme, host and port with the default | |
| 49 | + | // port filled in, "" for anything that is not an absolute http(s) URL. | |
| 50 | + | func originOf(raw string) string { return urlorigin.Canonical(raw) } | |
| 51 | + | ||
| 52 | + | // tokenAllowed reports whether the token of ra may be sent to its tarball URL: | |
| 53 | + | // the URL is on exactly the origin the host named, compared in canonical form | |
| 54 | + | // (default ports filled in, IPv6 hosts in brackets). | |
| 55 | + | func tokenAllowed(ra ResolvedAction) bool { | |
| 56 | + | o := originOf(ra.AuthOrigin) | |
| 57 | + | return ra.Token != "" && o != "" && o == originOf(ra.TarballURL) | |
| 59 | 58 | } | |
| 60 | 59 | ||
| 61 | 60 | // archiveClient returns the client that downloads the archives. It follows | |
| @@ -151,7 +150,7 @@ func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, f | |||
| 151 | 150 | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { | |
| 152 | 151 | url, token := ra.TarballURL, ra.Token | |
| 153 | 152 | // The token goes only to the origin the host named, never by the URL alone. | |
| 154 | - | if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) { | |
| 153 | + | if !tokenAllowed(ra) { | |
| 155 | 154 | token = "" | |
| 156 | 155 | } | |
| 157 | 156 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | |
| @@ -10,14 +10,14 @@ import ( | |||||
| 10 | "io" | 10 | "io" | ||
| 11 | "math/rand/v2" | 11 | "math/rand/v2" | ||
| 12 | "net/http" | 12 | "net/http" | ||
| 13 | - | "net/url" | |||
| 14 | "os" | 13 | "os" | ||
| 15 | "path/filepath" | 14 | "path/filepath" | ||
| 16 | "slices" | 15 | "slices" | ||
| 17 | - | "strings" | |||
| 18 | "time" | 16 | "time" | ||
| 19 | 17 | ||||
| 20 | "github.com/google/uuid" | 18 | "github.com/google/uuid" | ||
| 19 | + | ||||
| 20 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760). | 23 | // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760). | ||
| @@ -44,18 +44,17 @@ type errAccessDenied struct{ url string } | |||||
| 44 | 44 | ||||
| 45 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | 45 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | ||
| 46 | 46 | ||||
| 47 | - | // originOf is scheme://host[:port] of an absolute http(s) URL, lower case and | 47 | + | // originOf is the canonical origin of a URL (urlorigin.Canonical): the one the |
| 48 | - | // without the default port, "" for anything else. | 48 | + | // host and the executor both compare, scheme, host and port with the default |
| 49 | - | func originOf(raw string) string { | 49 | + | // port filled in, "" for anything that is not an absolute http(s) URL. |
| 50 | - | u, err := url.Parse(raw) | 50 | + | func originOf(raw string) string { return urlorigin.Canonical(raw) } |
| 51 | - | if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { | 51 | + | |
| 52 | - | return "" | 52 | + | // tokenAllowed reports whether the token of ra may be sent to its tarball URL: |
| 53 | - | } | 53 | + | // the URL is on exactly the origin the host named, compared in canonical form |
| 54 | - | host, port := strings.ToLower(u.Hostname()), u.Port() | 54 | + | // (default ports filled in, IPv6 hosts in brackets). |
| 55 | - | if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { | 55 | + | func tokenAllowed(ra ResolvedAction) bool { |
| 56 | - | return u.Scheme + "://" + host | 56 | + | o := originOf(ra.AuthOrigin) |
| 57 | - | } | 57 | + | return ra.Token != "" && o != "" && o == originOf(ra.TarballURL) |
| 58 | - | return u.Scheme + "://" + host + ":" + port | |||
| 59 | } | 58 | } | ||
| 60 | 59 | ||||
| 61 | // archiveClient returns the client that downloads the archives. It follows | 60 | // archiveClient returns the client that downloads the archives. It follows | ||
| @@ -151,7 +150,7 @@ func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, f | |||||
| 151 | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { | 150 | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { | ||
| 152 | url, token := ra.TarballURL, ra.Token | 151 | url, token := ra.TarballURL, ra.Token | ||
| 153 | // The token goes only to the origin the host named, never by the URL alone. | 152 | // The token goes only to the origin the host named, never by the URL alone. | ||
| 154 | - | if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) { | 153 | + | if !tokenAllowed(ra) { |
| 155 | token = "" | 154 | token = "" | ||
| 156 | } | 155 | } | ||
| 157 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | 156 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | ||
M internal/executor/actions_test.go +31 -0
| @@ -866,3 +866,34 @@ func TestArchiveRedirectChainNeverReauthorises(t *testing.T) { | |||
| 866 | 866 | } | |
| 867 | 867 | } | |
| 868 | 868 | } | |
| 869 | + | ||
| 870 | + | // GO-012: the token goes to the exact origin, in canonical form. | |
| 871 | + | func TestTokenAllowedOrigins(t *testing.T) { | |
| 872 | + | for _, tc := range []struct { | |
| 873 | + | url, origin string | |
| 874 | + | ok bool | |
| 875 | + | }{ | |
| 876 | + | {"https://ermite.example/x", "https://ermite.example", true}, | |
| 877 | + | {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way | |
| 878 | + | {"https://ermite.example/x", "https://ermite.example:443", true}, | |
| 879 | + | {"https://ERMITE.example/x", "https://ermite.example", true}, | |
| 880 | + | {"https://[::1]:8443/x", "https://[::1]:8443", true}, | |
| 881 | + | {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review | |
| 882 | + | {"https://[::1:8443]/x", "https://[::1]:8443", false}, | |
| 883 | + | {"https://[::1]/x", "https://[::1]:443", true}, | |
| 884 | + | {"https://[::1]/x", "https://[::1]:8443", false}, | |
| 885 | + | {"http://ermite.example/x", "https://ermite.example", false}, | |
| 886 | + | {"https://ermite.example:8443/x", "https://ermite.example", false}, | |
| 887 | + | {"https://sub.ermite.example/x", "https://ermite.example", false}, | |
| 888 | + | {"https://ermite.example/x", "", false}, | |
| 889 | + | {"https://ermite.example/x", "not an origin", false}, | |
| 890 | + | {"https://u:p@ermite.example/x", "https://ermite.example", false}, | |
| 891 | + | } { | |
| 892 | + | if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok { | |
| 893 | + | t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok) | |
| 894 | + | } | |
| 895 | + | } | |
| 896 | + | if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) { | |
| 897 | + | t.Error("no token, nothing to allow") | |
| 898 | + | } | |
| 899 | + | } | |
| @@ -866,3 +866,34 @@ func TestArchiveRedirectChainNeverReauthorises(t *testing.T) { | |||||
| 866 | } | 866 | } | ||
| 867 | } | 867 | } | ||
| 868 | } | 868 | } | ||
| 869 | + | ||||
| 870 | + | // GO-012: the token goes to the exact origin, in canonical form. | |||
| 871 | + | func TestTokenAllowedOrigins(t *testing.T) { | |||
| 872 | + | for _, tc := range []struct { | |||
| 873 | + | url, origin string | |||
| 874 | + | ok bool | |||
| 875 | + | }{ | |||
| 876 | + | {"https://ermite.example/x", "https://ermite.example", true}, | |||
| 877 | + | {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way | |||
| 878 | + | {"https://ermite.example/x", "https://ermite.example:443", true}, | |||
| 879 | + | {"https://ERMITE.example/x", "https://ermite.example", true}, | |||
| 880 | + | {"https://[::1]:8443/x", "https://[::1]:8443", true}, | |||
| 881 | + | {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review | |||
| 882 | + | {"https://[::1:8443]/x", "https://[::1]:8443", false}, | |||
| 883 | + | {"https://[::1]/x", "https://[::1]:443", true}, | |||
| 884 | + | {"https://[::1]/x", "https://[::1]:8443", false}, | |||
| 885 | + | {"http://ermite.example/x", "https://ermite.example", false}, | |||
| 886 | + | {"https://ermite.example:8443/x", "https://ermite.example", false}, | |||
| 887 | + | {"https://sub.ermite.example/x", "https://ermite.example", false}, | |||
| 888 | + | {"https://ermite.example/x", "", false}, | |||
| 889 | + | {"https://ermite.example/x", "not an origin", false}, | |||
| 890 | + | {"https://u:p@ermite.example/x", "https://ermite.example", false}, | |||
| 891 | + | } { | |||
| 892 | + | if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok { | |||
| 893 | + | t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok) | |||
| 894 | + | } | |||
| 895 | + | } | |||
| 896 | + | if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) { | |||
| 897 | + | t.Error("no token, nothing to allow") | |||
| 898 | + | } | |||
| 899 | + | } | |||
M internal/host/guestforge.go +6 -18
| @@ -8,8 +8,9 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | - | "net/url" | |
| 12 | 11 | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // serverURLKeys are the keys of the github context that actions use to reach | |
| @@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||
| 110 | 111 | } | |
| 111 | 112 | ||
| 112 | 113 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | |
| 113 | - | // without the default port, or "" for anything else (including URLs with | |
| 114 | - | // credentials, which are never rewritten). | |
| 115 | - | func originOf(raw string) string { | |
| 116 | - | u, err := url.Parse(raw) | |
| 117 | - | if err != nil || u.Host == "" || u.User != nil { | |
| 118 | - | return "" | |
| 119 | - | } | |
| 120 | - | scheme := strings.ToLower(u.Scheme) | |
| 121 | - | if scheme != "http" && scheme != "https" { | |
| 122 | - | return "" | |
| 123 | - | } | |
| 124 | - | host, port := strings.ToLower(u.Hostname()), u.Port() | |
| 125 | - | if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { | |
| 126 | - | return scheme + "://" + host | |
| 127 | - | } | |
| 128 | - | return scheme + "://" + host + ":" + port | |
| 129 | - | } | |
| 114 | + | // without the default port, an IPv6 host in brackets, or "" for anything else | |
| 115 | + | // (including URLs with credentials, which are never rewritten). It is | |
| 116 | + | // urlorigin.Prefix, the form the executor compares by urlorigin.Canonical. | |
| 117 | + | func originOf(raw string) string { return urlorigin.Prefix(raw) } | |
| 130 | 118 | ||
| 131 | 119 | func marshalNoEscape(v any) (json.RawMessage, error) { | |
| 132 | 120 | var b bytes.Buffer | |
| @@ -8,8 +8,9 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | - | "net/url" | |||
| 12 | "strings" | 11 | "strings" | ||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // serverURLKeys are the keys of the github context that actions use to reach | 16 | // serverURLKeys are the keys of the github context that actions use to reach | ||
| @@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||||
| 110 | } | 111 | } | ||
| 111 | 112 | ||||
| 112 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | 113 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | ||
| 113 | - | // without the default port, or "" for anything else (including URLs with | 114 | + | // without the default port, an IPv6 host in brackets, or "" for anything else |
| 114 | - | // credentials, which are never rewritten). | 115 | + | // (including URLs with credentials, which are never rewritten). It is |
| 115 | - | func originOf(raw string) string { | 116 | + | // urlorigin.Prefix, the form the executor compares by urlorigin.Canonical. |
| 116 | - | u, err := url.Parse(raw) | 117 | + | func originOf(raw string) string { return urlorigin.Prefix(raw) } |
| 117 | - | if err != nil || u.Host == "" || u.User != nil { | |||
| 118 | - | return "" | |||
| 119 | - | } | |||
| 120 | - | scheme := strings.ToLower(u.Scheme) | |||
| 121 | - | if scheme != "http" && scheme != "https" { | |||
| 122 | - | return "" | |||
| 123 | - | } | |||
| 124 | - | host, port := strings.ToLower(u.Hostname()), u.Port() | |||
| 125 | - | if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { | |||
| 126 | - | return scheme + "://" + host | |||
| 127 | - | } | |||
| 128 | - | return scheme + "://" + host + ":" + port | |||
| 129 | - | } | |||
| 130 | 118 | ||||
| 131 | func marshalNoEscape(v any) (json.RawMessage, error) { | 119 | func marshalNoEscape(v any) (json.RawMessage, error) { | ||
| 132 | var b bytes.Buffer | 120 | var b bytes.Buffer | ||
M internal/host/guestforge_test.go +19 -0
| @@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) { | |||
| 112 | 112 | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | |
| 113 | 113 | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | |
| 114 | 114 | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | |
| 115 | + | // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the | |
| 116 | + | // host "::1" on port 8443. | |
| 117 | + | "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]", | |
| 118 | + | "http://[2001:DB8::1]:80": "http://[2001:db8::1]", | |
| 115 | 119 | } { | |
| 116 | 120 | if got := originOf(in); got != want { | |
| 117 | 121 | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | |
| @@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) { | |||
| 146 | 150 | } | |
| 147 | 151 | } | |
| 148 | 152 | } | |
| 153 | + | ||
| 154 | + | // GO-012: guest forge access on an IPv6 address. The URLs of the contexts are | |
| 155 | + | // rewritten to the runner's origin, the brackets and the port kept, and the | |
| 156 | + | // origin of the other host with the look-alike spelling is not touched. | |
| 157 | + | func TestRewriteServerURLsIPv6(t *testing.T) { | |
| 158 | + | in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |
| 159 | + | got, err := RewriteServerURLs(in, "http://[fd00::2]:8088") | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |
| 164 | + | if got != want { | |
| 165 | + | t.Errorf("got %s\nwant %s", got, want) | |
| 166 | + | } | |
| 167 | + | } | |
| @@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) { | |||||
| 112 | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | 112 | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | ||
| 113 | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | 113 | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | ||
| 114 | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | 114 | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | ||
| 115 | + | // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the | |||
| 116 | + | // host "::1" on port 8443. | |||
| 117 | + | "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]", | |||
| 118 | + | "http://[2001:DB8::1]:80": "http://[2001:db8::1]", | |||
| 115 | } { | 119 | } { | ||
| 116 | if got := originOf(in); got != want { | 120 | if got := originOf(in); got != want { | ||
| 117 | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | 121 | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | ||
| @@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) { | |||||
| 146 | } | 150 | } | ||
| 147 | } | 151 | } | ||
| 148 | } | 152 | } | ||
| 153 | + | ||||
| 154 | + | // GO-012: guest forge access on an IPv6 address. The URLs of the contexts are | |||
| 155 | + | // rewritten to the runner's origin, the brackets and the port kept, and the | |||
| 156 | + | // origin of the other host with the look-alike spelling is not touched. | |||
| 157 | + | func TestRewriteServerURLsIPv6(t *testing.T) { | |||
| 158 | + | in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |||
| 159 | + | got, err := RewriteServerURLs(in, "http://[fd00::2]:8088") | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |||
| 164 | + | if got != want { | |||
| 165 | + | t.Errorf("got %s\nwant %s", got, want) | |||
| 166 | + | } | |||
| 167 | + | } | |||
M internal/host/resolve.go +4 -2
| @@ -77,8 +77,10 @@ func checkUses(u string) error { | |||
| 77 | 77 | return nil | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | // badPathRune: control characters (NUL included) and the backslash. | |
| 81 | - | func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' || r == utf8.RuneError } | |
| 80 | + | // badPathRune: control characters (NUL included) and the backslash. Invalid | |
| 81 | + | // UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a | |
| 82 | + | // valid character. | |
| 83 | + | func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' } | |
| 82 | 84 | ||
| 83 | 85 | func cut(s string) string { | |
| 84 | 86 | if len(s) > 80 { | |
| @@ -77,8 +77,10 @@ func checkUses(u string) error { | |||||
| 77 | return nil | 77 | return nil | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | - | // badPathRune: control characters (NUL included) and the backslash. | 80 | + | // badPathRune: control characters (NUL included) and the backslash. Invalid |
| 81 | - | func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' || r == utf8.RuneError } | 81 | + | // UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a |
| 82 | + | // valid character. | |||
| 83 | + | func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' } | |||
| 82 | 84 | ||||
| 83 | func cut(s string) string { | 85 | func cut(s string) string { | ||
| 84 | if len(s) > 80 { | 86 | if len(s) > 80 { | ||
M internal/host/resolve_test.go +27 -1
| @@ -115,10 +115,11 @@ func TestCheckUses(t *testing.T) { | |||
| 115 | 115 | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | |
| 116 | 116 | // GO-011: the path of the action is a path, not a repository name. | |
| 117 | 117 | "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true, | |
| 118 | + | "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character | |
| 118 | 119 | "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true, | |
| 119 | 120 | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | |
| 120 | 121 | "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false, | |
| 121 | - | "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, | |
| 122 | + | "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false, | |
| 122 | 123 | "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false, | |
| 123 | 124 | "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false, | |
| 124 | 125 | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | |
| @@ -277,3 +278,28 @@ func TestResolveResponseTokenOrigin(t *testing.T) { | |||
| 277 | 278 | t.Errorf("%+v", a) | |
| 278 | 279 | } | |
| 279 | 280 | } | |
| 281 | + | ||
| 282 | + | // GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is | |
| 283 | + | // exact, and a look-alike is not accepted. | |
| 284 | + | func TestResolveResponseTokenOriginIPv6(t *testing.T) { | |
| 285 | + | j := resolveJob(&resolveStub{}) | |
| 286 | + | j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088") | |
| 287 | + | ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE | |
| 288 | + | conv := func(url string) *execpb.ResolvedAction { | |
| 289 | + | return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0] | |
| 290 | + | } | |
| 291 | + | // The public origin is rewritten to the guest one. | |
| 292 | + | if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" { | |
| 293 | + | t.Errorf("%+v", a) | |
| 294 | + | } | |
| 295 | + | // The runner's own origin, default port. | |
| 296 | + | if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" { | |
| 297 | + | t.Errorf("%+v", a) | |
| 298 | + | } | |
| 299 | + | // The look-alike host "::1:8443" and other ports are not ermite. | |
| 300 | + | for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} { | |
| 301 | + | if a := conv(u); a.Token != "" || a.AuthOrigin != "" { | |
| 302 | + | t.Errorf("%s: token kept: %+v", u, a) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| @@ -115,10 +115,11 @@ func TestCheckUses(t *testing.T) { | |||||
| 115 | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | 115 | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | ||
| 116 | // GO-011: the path of the action is a path, not a repository name. | 116 | // GO-011: the path of the action is a path, not a repository name. | ||
| 117 | "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true, | 117 | "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true, | ||
| 118 | + | "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character | |||
| 118 | "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true, | 119 | "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true, | ||
| 119 | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | 120 | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | ||
| 120 | "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false, | 121 | "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false, | ||
| 121 | - | "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, | 122 | + | "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false, |
| 122 | "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false, | 123 | "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false, | ||
| 123 | "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false, | 124 | "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false, | ||
| 124 | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | 125 | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | ||
| @@ -277,3 +278,28 @@ func TestResolveResponseTokenOrigin(t *testing.T) { | |||||
| 277 | t.Errorf("%+v", a) | 278 | t.Errorf("%+v", a) | ||
| 278 | } | 279 | } | ||
| 279 | } | 280 | } | ||
| 281 | + | ||||
| 282 | + | // GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is | |||
| 283 | + | // exact, and a look-alike is not accepted. | |||
| 284 | + | func TestResolveResponseTokenOriginIPv6(t *testing.T) { | |||
| 285 | + | j := resolveJob(&resolveStub{}) | |||
| 286 | + | j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088") | |||
| 287 | + | ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE | |||
| 288 | + | conv := func(url string) *execpb.ResolvedAction { | |||
| 289 | + | return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0] | |||
| 290 | + | } | |||
| 291 | + | // The public origin is rewritten to the guest one. | |||
| 292 | + | if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" { | |||
| 293 | + | t.Errorf("%+v", a) | |||
| 294 | + | } | |||
| 295 | + | // The runner's own origin, default port. | |||
| 296 | + | if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" { | |||
| 297 | + | t.Errorf("%+v", a) | |||
| 298 | + | } | |||
| 299 | + | // The look-alike host "::1:8443" and other ports are not ermite. | |||
| 300 | + | for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} { | |||
| 301 | + | if a := conv(u); a.Token != "" || a.AuthOrigin != "" { | |||
| 302 | + | t.Errorf("%s: token kept: %+v", u, a) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | } | |||
A internal/urlorigin/urlorigin.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package urlorigin gives the one canonical form of the origin of an http(s) | |
| 4 | + | // URL that the host and the executor both compare: the archive token of an | |
| 5 | + | // action goes only to the origin the host named, so the two sides must agree on | |
| 6 | + | // what "the same origin" means, whatever the spelling of the URL. | |
| 7 | + | package urlorigin | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "net" | |
| 11 | + | "net/url" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Canonical returns "scheme://host:port" for an absolute http or https URL: the | |
| 16 | + | // scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6 | |
| 17 | + | // host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and | |
| 18 | + | // "https://[::1:8443]/x" (a different host, without a port) differ, and | |
| 19 | + | // "https://a.test" and "https://a.test:443" are the same. It returns "" for | |
| 20 | + | // anything else, including a URL with credentials, which never carries a token. | |
| 21 | + | func Canonical(raw string) string { | |
| 22 | + | u, err := url.Parse(raw) | |
| 23 | + | if err != nil || u.Host == "" || u.User != nil { | |
| 24 | + | return "" | |
| 25 | + | } | |
| 26 | + | scheme := strings.ToLower(u.Scheme) | |
| 27 | + | port, ok := effectivePort(scheme, u.Port()) | |
| 28 | + | if !ok || u.Hostname() == "" { | |
| 29 | + | return "" | |
| 30 | + | } | |
| 31 | + | return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func effectivePort(scheme, port string) (string, bool) { | |
| 35 | + | switch { | |
| 36 | + | case port != "": | |
| 37 | + | if scheme != "http" && scheme != "https" { | |
| 38 | + | return "", false | |
| 39 | + | } | |
| 40 | + | return port, true | |
| 41 | + | case scheme == "http": | |
| 42 | + | return "80", true | |
| 43 | + | case scheme == "https": | |
| 44 | + | return "443", true | |
| 45 | + | } | |
| 46 | + | return "", false | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Prefix returns the origin as it is written at the start of a URL: the same | |
| 50 | + | // as Canonical without the default port, which is how a URL is built from it. | |
| 51 | + | func Prefix(raw string) string { | |
| 52 | + | c := Canonical(raw) | |
| 53 | + | switch { | |
| 54 | + | case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"): | |
| 55 | + | return strings.TrimSuffix(c, ":80") | |
| 56 | + | case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"): | |
| 57 | + | return strings.TrimSuffix(c, ":443") | |
| 58 | + | } | |
| 59 | + | return c | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package urlorigin gives the one canonical form of the origin of an http(s) | |||
| 4 | + | // URL that the host and the executor both compare: the archive token of an | |||
| 5 | + | // action goes only to the origin the host named, so the two sides must agree on | |||
| 6 | + | // what "the same origin" means, whatever the spelling of the URL. | |||
| 7 | + | package urlorigin | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "net" | |||
| 11 | + | "net/url" | |||
| 12 | + | "strings" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Canonical returns "scheme://host:port" for an absolute http or https URL: the | |||
| 16 | + | // scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6 | |||
| 17 | + | // host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and | |||
| 18 | + | // "https://[::1:8443]/x" (a different host, without a port) differ, and | |||
| 19 | + | // "https://a.test" and "https://a.test:443" are the same. It returns "" for | |||
| 20 | + | // anything else, including a URL with credentials, which never carries a token. | |||
| 21 | + | func Canonical(raw string) string { | |||
| 22 | + | u, err := url.Parse(raw) | |||
| 23 | + | if err != nil || u.Host == "" || u.User != nil { | |||
| 24 | + | return "" | |||
| 25 | + | } | |||
| 26 | + | scheme := strings.ToLower(u.Scheme) | |||
| 27 | + | port, ok := effectivePort(scheme, u.Port()) | |||
| 28 | + | if !ok || u.Hostname() == "" { | |||
| 29 | + | return "" | |||
| 30 | + | } | |||
| 31 | + | return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func effectivePort(scheme, port string) (string, bool) { | |||
| 35 | + | switch { | |||
| 36 | + | case port != "": | |||
| 37 | + | if scheme != "http" && scheme != "https" { | |||
| 38 | + | return "", false | |||
| 39 | + | } | |||
| 40 | + | return port, true | |||
| 41 | + | case scheme == "http": | |||
| 42 | + | return "80", true | |||
| 43 | + | case scheme == "https": | |||
| 44 | + | return "443", true | |||
| 45 | + | } | |||
| 46 | + | return "", false | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Prefix returns the origin as it is written at the start of a URL: the same | |||
| 50 | + | // as Canonical without the default port, which is how a URL is built from it. | |||
| 51 | + | func Prefix(raw string) string { | |||
| 52 | + | c := Canonical(raw) | |||
| 53 | + | switch { | |||
| 54 | + | case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"): | |||
| 55 | + | return strings.TrimSuffix(c, ":80") | |||
| 56 | + | case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"): | |||
| 57 | + | return strings.TrimSuffix(c, ":443") | |||
| 58 | + | } | |||
| 59 | + | return c | |||
| 60 | + | } | |||
A internal/urlorigin/urlorigin_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package urlorigin | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestCanonical(t *testing.T) { | |
| 8 | + | for in, want := range map[string]string{ | |
| 9 | + | "https://Ermite.example/x": "https://ermite.example:443", | |
| 10 | + | "HTTPS://ermite.example": "https://ermite.example:443", | |
| 11 | + | "https://ermite.example:443/x": "https://ermite.example:443", | |
| 12 | + | "http://a.test:80": "http://a.test:80", | |
| 13 | + | "http://a.test": "http://a.test:80", | |
| 14 | + | "http://a.test:8088/p?q#f": "http://a.test:8088", | |
| 15 | + | "https://[::1]:8443/x": "https://[::1]:8443", | |
| 16 | + | "https://[::1]/x": "https://[::1]:443", | |
| 17 | + | "https://[::1]:443": "https://[::1]:443", | |
| 18 | + | "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443", | |
| 19 | + | "http://[::1]:80": "http://[::1]:80", | |
| 20 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", | |
| 21 | + | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "", | |
| 22 | + | "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all | |
| 23 | + | } { | |
| 24 | + | if got := Canonical(in); got != want { | |
| 25 | + | t.Errorf("Canonical(%q) = %q, want %q", in, got, want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | // GO-012: the host ::1 on port 8443 is not the host ::1:8443. | |
| 29 | + | if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") { | |
| 30 | + | t.Error("two different origins collide") | |
| 31 | + | } | |
| 32 | + | // Spellings of one origin agree. | |
| 33 | + | for _, pair := range [][2]string{ | |
| 34 | + | {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"}, | |
| 35 | + | } { | |
| 36 | + | if Canonical(pair[0]) != Canonical(pair[1]) { | |
| 37 | + | t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1])) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | // Other ports, schemes and hosts differ. | |
| 41 | + | for _, pair := range [][2]string{ | |
| 42 | + | {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"}, | |
| 43 | + | } { | |
| 44 | + | if Canonical(pair[0]) == Canonical(pair[1]) { | |
| 45 | + | t.Errorf("%q and %q collide", pair[0], pair[1]) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestPrefix(t *testing.T) { | |
| 51 | + | for in, want := range map[string]string{ | |
| 52 | + | "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088", | |
| 53 | + | "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443", | |
| 54 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "", | |
| 55 | + | } { | |
| 56 | + | if got := Prefix(in); got != want { | |
| 57 | + | t.Errorf("Prefix(%q) = %q, want %q", in, got, want) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package urlorigin | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | func TestCanonical(t *testing.T) { | |||
| 8 | + | for in, want := range map[string]string{ | |||
| 9 | + | "https://Ermite.example/x": "https://ermite.example:443", | |||
| 10 | + | "HTTPS://ermite.example": "https://ermite.example:443", | |||
| 11 | + | "https://ermite.example:443/x": "https://ermite.example:443", | |||
| 12 | + | "http://a.test:80": "http://a.test:80", | |||
| 13 | + | "http://a.test": "http://a.test:80", | |||
| 14 | + | "http://a.test:8088/p?q#f": "http://a.test:8088", | |||
| 15 | + | "https://[::1]:8443/x": "https://[::1]:8443", | |||
| 16 | + | "https://[::1]/x": "https://[::1]:443", | |||
| 17 | + | "https://[::1]:443": "https://[::1]:443", | |||
| 18 | + | "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443", | |||
| 19 | + | "http://[::1]:80": "http://[::1]:80", | |||
| 20 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", | |||
| 21 | + | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "", | |||
| 22 | + | "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all | |||
| 23 | + | } { | |||
| 24 | + | if got := Canonical(in); got != want { | |||
| 25 | + | t.Errorf("Canonical(%q) = %q, want %q", in, got, want) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | // GO-012: the host ::1 on port 8443 is not the host ::1:8443. | |||
| 29 | + | if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") { | |||
| 30 | + | t.Error("two different origins collide") | |||
| 31 | + | } | |||
| 32 | + | // Spellings of one origin agree. | |||
| 33 | + | for _, pair := range [][2]string{ | |||
| 34 | + | {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"}, | |||
| 35 | + | } { | |||
| 36 | + | if Canonical(pair[0]) != Canonical(pair[1]) { | |||
| 37 | + | t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1])) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | // Other ports, schemes and hosts differ. | |||
| 41 | + | for _, pair := range [][2]string{ | |||
| 42 | + | {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"}, | |||
| 43 | + | } { | |||
| 44 | + | if Canonical(pair[0]) == Canonical(pair[1]) { | |||
| 45 | + | t.Errorf("%q and %q collide", pair[0], pair[1]) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestPrefix(t *testing.T) { | |||
| 51 | + | for in, want := range map[string]string{ | |||
| 52 | + | "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088", | |||
| 53 | + | "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443", | |||
| 54 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "", | |||
| 55 | + | } { | |||
| 56 | + | if got := Prefix(in); got != want { | |||
| 57 | + | t.Errorf("Prefix(%q) = %q, want %q", in, got, want) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||