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