]>
Commit | Line | Data |
---|---|---|
15c0b25d AP |
1 | // Package svchost deals with the representations of the so-called "friendly |
2 | // hostnames" that we use to represent systems that provide Terraform-native | |
3 | // remote services, such as module registry, remote operations, etc. | |
4 | // | |
5 | // Friendly hostnames are specified such that, as much as possible, they | |
6 | // are consistent with how web browsers think of hostnames, so that users | |
7 | // can bring their intuitions about how hostnames behave when they access | |
8 | // a Terraform Enterprise instance's web UI (or indeed any other website) | |
9 | // and have this behave in a similar way. | |
10 | package svchost | |
11 | ||
12 | import ( | |
13 | "errors" | |
14 | "fmt" | |
15 | "strconv" | |
16 | "strings" | |
17 | ||
18 | "golang.org/x/net/idna" | |
19 | ) | |
20 | ||
21 | // Hostname is specialized name for string that indicates that the string | |
22 | // has been converted to (or was already in) the storage and comparison form. | |
23 | // | |
24 | // Hostname values are not suitable for display in the user-interface. Use | |
25 | // the ForDisplay method to obtain a form suitable for display in the UI. | |
26 | // | |
27 | // Unlike user-supplied hostnames, strings of type Hostname (assuming they | |
28 | // were constructed by a function within this package) can be compared for | |
29 | // equality using the standard Go == operator. | |
30 | type Hostname string | |
31 | ||
32 | // acePrefix is the ASCII Compatible Encoding prefix, used to indicate that | |
33 | // a domain name label is in "punycode" form. | |
34 | const acePrefix = "xn--" | |
35 | ||
36 | // displayProfile is a very liberal idna profile that we use to do | |
37 | // normalization for display without imposing validation rules. | |
38 | var displayProfile = idna.New( | |
39 | idna.MapForLookup(), | |
40 | idna.Transitional(true), | |
41 | ) | |
42 | ||
43 | // ForDisplay takes a user-specified hostname and returns a normalized form of | |
44 | // it suitable for display in the UI. | |
45 | // | |
46 | // If the input is so invalid that no normalization can be performed then | |
47 | // this will return the input, assuming that the caller still wants to | |
48 | // display _something_. This function is, however, more tolerant than the | |
49 | // other functions in this package and will make a best effort to prepare | |
50 | // _any_ given hostname for display. | |
51 | // | |
52 | // For validation, use either IsValid (for explicit validation) or | |
53 | // ForComparison (which implicitly validates, returning an error if invalid). | |
54 | func ForDisplay(given string) string { | |
55 | var portPortion string | |
56 | if colonPos := strings.Index(given, ":"); colonPos != -1 { | |
57 | given, portPortion = given[:colonPos], given[colonPos:] | |
58 | } | |
59 | portPortion, _ = normalizePortPortion(portPortion) | |
60 | ||
61 | ascii, err := displayProfile.ToASCII(given) | |
62 | if err != nil { | |
63 | return given + portPortion | |
64 | } | |
65 | display, err := displayProfile.ToUnicode(ascii) | |
66 | if err != nil { | |
67 | return given + portPortion | |
68 | } | |
69 | return display + portPortion | |
70 | } | |
71 | ||
72 | // IsValid returns true if the given user-specified hostname is a valid | |
73 | // service hostname. | |
74 | // | |
75 | // Validity is determined by complying with the RFC 5891 requirements for | |
76 | // names that are valid for domain lookup (section 5), with the additional | |
77 | // requirement that user-supplied forms must not _already_ contain | |
78 | // Punycode segments. | |
79 | func IsValid(given string) bool { | |
80 | _, err := ForComparison(given) | |
81 | return err == nil | |
82 | } | |
83 | ||
84 | // ForComparison takes a user-specified hostname and returns a normalized | |
85 | // form of it suitable for storage and comparison. The result is not suitable | |
86 | // for display to end-users because it uses Punycode to represent non-ASCII | |
87 | // characters, and this form is unreadable for non-ASCII-speaking humans. | |
88 | // | |
89 | // The result is typed as Hostname -- a specialized name for string -- so that | |
90 | // other APIs can make it clear within the type system whether they expect a | |
91 | // user-specified or display-form hostname or a value already normalized for | |
92 | // comparison. | |
93 | // | |
94 | // The returned Hostname is not valid if the returned error is non-nil. | |
95 | func ForComparison(given string) (Hostname, error) { | |
96 | var portPortion string | |
97 | if colonPos := strings.Index(given, ":"); colonPos != -1 { | |
98 | given, portPortion = given[:colonPos], given[colonPos:] | |
99 | } | |
100 | ||
101 | var err error | |
102 | portPortion, err = normalizePortPortion(portPortion) | |
103 | if err != nil { | |
104 | return Hostname(""), err | |
105 | } | |
106 | ||
107 | if given == "" { | |
108 | return Hostname(""), fmt.Errorf("empty string is not a valid hostname") | |
109 | } | |
110 | ||
111 | // First we'll apply our additional constraint that Punycode must not | |
112 | // be given directly by the user. This is not an IDN specification | |
113 | // requirement, but we prohibit it to force users to use human-readable | |
114 | // hostname forms within Terraform configuration. | |
115 | labels := labelIter{orig: given} | |
116 | for ; !labels.done(); labels.next() { | |
117 | label := labels.label() | |
118 | if label == "" { | |
119 | return Hostname(""), fmt.Errorf( | |
120 | "hostname contains empty label (two consecutive periods)", | |
121 | ) | |
122 | } | |
123 | if strings.HasPrefix(label, acePrefix) { | |
124 | return Hostname(""), fmt.Errorf( | |
125 | "hostname label %q specified in punycode format; service hostnames must be given in unicode", | |
126 | label, | |
127 | ) | |
128 | } | |
129 | } | |
130 | ||
131 | result, err := idna.Lookup.ToASCII(given) | |
132 | if err != nil { | |
133 | return Hostname(""), err | |
134 | } | |
135 | return Hostname(result + portPortion), nil | |
136 | } | |
137 | ||
138 | // ForDisplay returns a version of the receiver that is appropriate for display | |
139 | // in the UI. This includes converting any punycode labels to their | |
140 | // corresponding Unicode characters. | |
141 | // | |
142 | // A round-trip through ForComparison and this ForDisplay method does not | |
143 | // guarantee the same result as calling this package's top-level ForDisplay | |
144 | // function, since a round-trip through the Hostname type implies stricter | |
145 | // handling than we do when doing basic display-only processing. | |
146 | func (h Hostname) ForDisplay() string { | |
147 | given := string(h) | |
148 | var portPortion string | |
149 | if colonPos := strings.Index(given, ":"); colonPos != -1 { | |
150 | given, portPortion = given[:colonPos], given[colonPos:] | |
151 | } | |
152 | // We don't normalize the port portion here because we assume it's | |
153 | // already been normalized on the way in. | |
154 | ||
155 | result, err := idna.Lookup.ToUnicode(given) | |
156 | if err != nil { | |
157 | // Should never happen, since type Hostname indicates that a string | |
158 | // passed through our validation rules. | |
159 | panic(fmt.Errorf("ForDisplay called on invalid Hostname: %s", err)) | |
160 | } | |
161 | return result + portPortion | |
162 | } | |
163 | ||
164 | func (h Hostname) String() string { | |
165 | return string(h) | |
166 | } | |
167 | ||
168 | func (h Hostname) GoString() string { | |
169 | return fmt.Sprintf("svchost.Hostname(%q)", string(h)) | |
170 | } | |
171 | ||
172 | // normalizePortPortion attempts to normalize the "port portion" of a hostname, | |
173 | // which begins with the first colon in the hostname and should be followed | |
174 | // by a string of decimal digits. | |
175 | // | |
176 | // If the port portion is valid, a normalized version of it is returned along | |
177 | // with a nil error. | |
178 | // | |
179 | // If the port portion is invalid, the input string is returned verbatim along | |
180 | // with a non-nil error. | |
181 | // | |
182 | // An empty string is a valid port portion representing the absense of a port. | |
183 | // If non-empty, the first character must be a colon. | |
184 | func normalizePortPortion(s string) (string, error) { | |
185 | if s == "" { | |
186 | return s, nil | |
187 | } | |
188 | ||
189 | if s[0] != ':' { | |
190 | // should never happen, since caller tends to guarantee the presence | |
191 | // of a colon due to how it's extracted from the string. | |
192 | return s, errors.New("port portion is missing its initial colon") | |
193 | } | |
194 | ||
195 | numStr := s[1:] | |
196 | num, err := strconv.Atoi(numStr) | |
197 | if err != nil { | |
198 | return s, errors.New("port portion contains non-digit characters") | |
199 | } | |
200 | if num == 443 { | |
201 | return "", nil // ":443" is the default | |
202 | } | |
203 | if num > 65535 { | |
204 | return s, errors.New("port number is greater than 65535") | |
205 | } | |
206 | return fmt.Sprintf(":%d", num), nil | |
207 | } |