opentofu/svchost/svchost.go
Martin Atkins db08ee4ac5 svchost: new package for wrangling service hostnames
We're starting to expose a number of so-called "Terraform-native services"
that can be offered under a friendly hostname. The first of these will
be module registry services, as they expand from the public
Terraform Registry to private registry services within Terraform
Enterprise and elsewhere.

This package is for wrangling these "friendly hostnames", which start
their lives as user-specified unicode strings, can be converted to
Punycode for storage and comparison, and can in turn be converted back
into normalized unicode for display to the user.
2017-10-19 11:18:43 -07:00

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 absense 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
}