uri.go (3848B)
1 package rulesfn 2 3 import ( 4 "fmt" 5 "net" 6 "net/url" 7 "strings" 8 9 smithyhttp "github.com/aws/smithy-go/transport/http" 10 ) 11 12 // IsValidHostLabel returns if the input is a single valid [RFC 1123] host 13 // label. If allowSubDomains is true, will allow validation to include nested 14 // host labels. Returns false if the input is not a valid host label. If errors 15 // occur they will be added to the provided [ErrorCollector]. 16 // 17 // [RFC 1123]: https://www.ietf.org/rfc/rfc1123.txt 18 func IsValidHostLabel(input string, allowSubDomains bool) bool { 19 var labels []string 20 if allowSubDomains { 21 labels = strings.Split(input, ".") 22 } else { 23 labels = []string{input} 24 } 25 26 for _, label := range labels { 27 if !smithyhttp.ValidHostLabel(label) { 28 return false 29 } 30 if label[0] == '-' || label[len(label)-1] == '-' { 31 return false 32 } 33 } 34 35 return true 36 } 37 38 // ParseURL returns a [URL] if the provided string could be parsed. Returns nil 39 // if the string could not be parsed. Any parsing error will be added to the 40 // [ErrorCollector]. 41 // 42 // If the input URL string contains an IP6 address with a zone index. The 43 // returned [builtin.URL.Authority] value will contain the percent escaped (%) 44 // zone index separator. 45 func ParseURL(input string) *URL { 46 u, err := url.Parse(input) 47 if err != nil { 48 return nil 49 } 50 51 if u.RawQuery != "" { 52 return nil 53 } 54 55 if u.Scheme != "http" && u.Scheme != "https" { 56 return nil 57 } 58 59 normalizedPath := u.Path 60 if !strings.HasPrefix(normalizedPath, "/") { 61 normalizedPath = "/" + normalizedPath 62 } 63 if !strings.HasSuffix(normalizedPath, "/") { 64 normalizedPath = normalizedPath + "/" 65 } 66 67 // IP6 hosts may have zone indexes that need to be escaped to be valid in a 68 // URI. The Go URL parser will unescape the `%25` into `%`. This needs to 69 // be reverted since the returned URL will be used in string builders. 70 authority := strings.ReplaceAll(u.Host, "%", "%25") 71 72 return &URL{ 73 Scheme: u.Scheme, 74 Authority: authority, 75 Path: u.Path, 76 NormalizedPath: normalizedPath, 77 IsIp: net.ParseIP(hostnameWithoutZone(u)) != nil, 78 } 79 } 80 81 // URL provides the structure describing the parts of a parsed URL returned by 82 // [ParseURL]. 83 type URL struct { 84 Scheme string // https://www.rfc-editor.org/rfc/rfc3986#section-3.1 85 Authority string // https://www.rfc-editor.org/rfc/rfc3986#section-3.2 86 Path string // https://www.rfc-editor.org/rfc/rfc3986#section-3.3 87 NormalizedPath string // https://www.rfc-editor.org/rfc/rfc3986#section-6.2.3 88 IsIp bool 89 } 90 91 // URIEncode returns an percent-encoded [RFC3986 section 2.1] version of the 92 // input string. 93 // 94 // [RFC3986 section 2.1]: https://www.rfc-editor.org/rfc/rfc3986#section-2.1 95 func URIEncode(input string) string { 96 var output strings.Builder 97 for _, c := range []byte(input) { 98 if validPercentEncodedChar(c) { 99 output.WriteByte(c) 100 continue 101 } 102 103 fmt.Fprintf(&output, "%%%X", c) 104 } 105 106 return output.String() 107 } 108 109 func validPercentEncodedChar(c byte) bool { 110 return (c >= 'a' && c <= 'z') || 111 (c >= 'A' && c <= 'Z') || 112 (c >= '0' && c <= '9') || 113 c == '-' || c == '_' || c == '.' || c == '~' 114 } 115 116 // hostname implements u.Hostname() but strips the ipv6 zone ID (if present) 117 // such that net.ParseIP can still recognize IPv6 addresses with zone IDs. 118 // 119 // FUTURE(10/2023): netip.ParseAddr handles this natively but we can't take 120 // that package as a dependency yet due to our min go version (1.15, netip 121 // starts in 1.18). When we align with go runtime deprecation policy in 122 // 10/2023, we can remove this. 123 func hostnameWithoutZone(u *url.URL) string { 124 full := u.Hostname() 125 126 // this more or less mimics the internals of net/ (see unexported 127 // splitHostZone in that source) but throws the zone away because we don't 128 // need it 129 if i := strings.LastIndex(full, "%"); i > -1 { 130 return full[:i] 131 } 132 return full 133 }