208 lines
7.1 KiB
Go
208 lines
7.1 KiB
Go
// 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
|
|
}
|