[VOL-3678] First implementation of the BBSim-sadis-server
Change-Id: I5077a8f861f4cc6af9759f31a4a415042c05eba3
diff --git a/vendor/k8s.io/apimachinery/pkg/util/net/http.go b/vendor/k8s.io/apimachinery/pkg/util/net/http.go
new file mode 100644
index 0000000..945886c
--- /dev/null
+++ b/vendor/k8s.io/apimachinery/pkg/util/net/http.go
@@ -0,0 +1,724 @@
+/*
+Copyright 2016 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package net
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "golang.org/x/net/http2"
+ "k8s.io/klog/v2"
+)
+
+// JoinPreservingTrailingSlash does a path.Join of the specified elements,
+// preserving any trailing slash on the last non-empty segment
+func JoinPreservingTrailingSlash(elem ...string) string {
+ // do the basic path join
+ result := path.Join(elem...)
+
+ // find the last non-empty segment
+ for i := len(elem) - 1; i >= 0; i-- {
+ if len(elem[i]) > 0 {
+ // if the last segment ended in a slash, ensure our result does as well
+ if strings.HasSuffix(elem[i], "/") && !strings.HasSuffix(result, "/") {
+ result += "/"
+ }
+ break
+ }
+ }
+
+ return result
+}
+
+// IsTimeout returns true if the given error is a network timeout error
+func IsTimeout(err error) bool {
+ var neterr net.Error
+ if errors.As(err, &neterr) {
+ return neterr != nil && neterr.Timeout()
+ }
+ return false
+}
+
+// IsProbableEOF returns true if the given error resembles a connection termination
+// scenario that would justify assuming that the watch is empty.
+// These errors are what the Go http stack returns back to us which are general
+// connection closure errors (strongly correlated) and callers that need to
+// differentiate probable errors in connection behavior between normal "this is
+// disconnected" should use the method.
+func IsProbableEOF(err error) bool {
+ if err == nil {
+ return false
+ }
+ var uerr *url.Error
+ if errors.As(err, &uerr) {
+ err = uerr.Err
+ }
+ msg := err.Error()
+ switch {
+ case err == io.EOF:
+ return true
+ case err == io.ErrUnexpectedEOF:
+ return true
+ case msg == "http: can't write HTTP request on broken connection":
+ return true
+ case strings.Contains(msg, "http2: server sent GOAWAY and closed the connection"):
+ return true
+ case strings.Contains(msg, "connection reset by peer"):
+ return true
+ case strings.Contains(strings.ToLower(msg), "use of closed network connection"):
+ return true
+ }
+ return false
+}
+
+var defaultTransport = http.DefaultTransport.(*http.Transport)
+
+// SetOldTransportDefaults applies the defaults from http.DefaultTransport
+// for the Proxy, Dial, and TLSHandshakeTimeout fields if unset
+func SetOldTransportDefaults(t *http.Transport) *http.Transport {
+ if t.Proxy == nil || isDefault(t.Proxy) {
+ // http.ProxyFromEnvironment doesn't respect CIDRs and that makes it impossible to exclude things like pod and service IPs from proxy settings
+ // ProxierWithNoProxyCIDR allows CIDR rules in NO_PROXY
+ t.Proxy = NewProxierWithNoProxyCIDR(http.ProxyFromEnvironment)
+ }
+ // If no custom dialer is set, use the default context dialer
+ if t.DialContext == nil && t.Dial == nil {
+ t.DialContext = defaultTransport.DialContext
+ }
+ if t.TLSHandshakeTimeout == 0 {
+ t.TLSHandshakeTimeout = defaultTransport.TLSHandshakeTimeout
+ }
+ if t.IdleConnTimeout == 0 {
+ t.IdleConnTimeout = defaultTransport.IdleConnTimeout
+ }
+ return t
+}
+
+// SetTransportDefaults applies the defaults from http.DefaultTransport
+// for the Proxy, Dial, and TLSHandshakeTimeout fields if unset
+func SetTransportDefaults(t *http.Transport) *http.Transport {
+ t = SetOldTransportDefaults(t)
+ // Allow clients to disable http2 if needed.
+ if s := os.Getenv("DISABLE_HTTP2"); len(s) > 0 {
+ klog.Infof("HTTP2 has been explicitly disabled")
+ } else if allowsHTTP2(t) {
+ if err := http2.ConfigureTransport(t); err != nil {
+ klog.Warningf("Transport failed http2 configuration: %v", err)
+ }
+ }
+ return t
+}
+
+func allowsHTTP2(t *http.Transport) bool {
+ if t.TLSClientConfig == nil || len(t.TLSClientConfig.NextProtos) == 0 {
+ // the transport expressed no NextProto preference, allow
+ return true
+ }
+ for _, p := range t.TLSClientConfig.NextProtos {
+ if p == http2.NextProtoTLS {
+ // the transport explicitly allowed http/2
+ return true
+ }
+ }
+ // the transport explicitly set NextProtos and excluded http/2
+ return false
+}
+
+type RoundTripperWrapper interface {
+ http.RoundTripper
+ WrappedRoundTripper() http.RoundTripper
+}
+
+type DialFunc func(ctx context.Context, net, addr string) (net.Conn, error)
+
+func DialerFor(transport http.RoundTripper) (DialFunc, error) {
+ if transport == nil {
+ return nil, nil
+ }
+
+ switch transport := transport.(type) {
+ case *http.Transport:
+ // transport.DialContext takes precedence over transport.Dial
+ if transport.DialContext != nil {
+ return transport.DialContext, nil
+ }
+ // adapt transport.Dial to the DialWithContext signature
+ if transport.Dial != nil {
+ return func(ctx context.Context, net, addr string) (net.Conn, error) {
+ return transport.Dial(net, addr)
+ }, nil
+ }
+ // otherwise return nil
+ return nil, nil
+ case RoundTripperWrapper:
+ return DialerFor(transport.WrappedRoundTripper())
+ default:
+ return nil, fmt.Errorf("unknown transport type: %T", transport)
+ }
+}
+
+type TLSClientConfigHolder interface {
+ TLSClientConfig() *tls.Config
+}
+
+func TLSClientConfig(transport http.RoundTripper) (*tls.Config, error) {
+ if transport == nil {
+ return nil, nil
+ }
+
+ switch transport := transport.(type) {
+ case *http.Transport:
+ return transport.TLSClientConfig, nil
+ case TLSClientConfigHolder:
+ return transport.TLSClientConfig(), nil
+ case RoundTripperWrapper:
+ return TLSClientConfig(transport.WrappedRoundTripper())
+ default:
+ return nil, fmt.Errorf("unknown transport type: %T", transport)
+ }
+}
+
+func FormatURL(scheme string, host string, port int, path string) *url.URL {
+ return &url.URL{
+ Scheme: scheme,
+ Host: net.JoinHostPort(host, strconv.Itoa(port)),
+ Path: path,
+ }
+}
+
+func GetHTTPClient(req *http.Request) string {
+ if ua := req.UserAgent(); len(ua) != 0 {
+ return ua
+ }
+ return "unknown"
+}
+
+// SourceIPs splits the comma separated X-Forwarded-For header and joins it with
+// the X-Real-Ip header and/or req.RemoteAddr, ignoring invalid IPs.
+// The X-Real-Ip is omitted if it's already present in the X-Forwarded-For chain.
+// The req.RemoteAddr is always the last IP in the returned list.
+// It returns nil if all of these are empty or invalid.
+func SourceIPs(req *http.Request) []net.IP {
+ var srcIPs []net.IP
+
+ hdr := req.Header
+ // First check the X-Forwarded-For header for requests via proxy.
+ hdrForwardedFor := hdr.Get("X-Forwarded-For")
+ if hdrForwardedFor != "" {
+ // X-Forwarded-For can be a csv of IPs in case of multiple proxies.
+ // Use the first valid one.
+ parts := strings.Split(hdrForwardedFor, ",")
+ for _, part := range parts {
+ ip := net.ParseIP(strings.TrimSpace(part))
+ if ip != nil {
+ srcIPs = append(srcIPs, ip)
+ }
+ }
+ }
+
+ // Try the X-Real-Ip header.
+ hdrRealIp := hdr.Get("X-Real-Ip")
+ if hdrRealIp != "" {
+ ip := net.ParseIP(hdrRealIp)
+ // Only append the X-Real-Ip if it's not already contained in the X-Forwarded-For chain.
+ if ip != nil && !containsIP(srcIPs, ip) {
+ srcIPs = append(srcIPs, ip)
+ }
+ }
+
+ // Always include the request Remote Address as it cannot be easily spoofed.
+ var remoteIP net.IP
+ // Remote Address in Go's HTTP server is in the form host:port so we need to split that first.
+ host, _, err := net.SplitHostPort(req.RemoteAddr)
+ if err == nil {
+ remoteIP = net.ParseIP(host)
+ }
+ // Fallback if Remote Address was just IP.
+ if remoteIP == nil {
+ remoteIP = net.ParseIP(req.RemoteAddr)
+ }
+
+ // Don't duplicate remote IP if it's already the last address in the chain.
+ if remoteIP != nil && (len(srcIPs) == 0 || !remoteIP.Equal(srcIPs[len(srcIPs)-1])) {
+ srcIPs = append(srcIPs, remoteIP)
+ }
+
+ return srcIPs
+}
+
+// Checks whether the given IP address is contained in the list of IPs.
+func containsIP(ips []net.IP, ip net.IP) bool {
+ for _, v := range ips {
+ if v.Equal(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// Extracts and returns the clients IP from the given request.
+// Looks at X-Forwarded-For header, X-Real-Ip header and request.RemoteAddr in that order.
+// Returns nil if none of them are set or is set to an invalid value.
+func GetClientIP(req *http.Request) net.IP {
+ ips := SourceIPs(req)
+ if len(ips) == 0 {
+ return nil
+ }
+ return ips[0]
+}
+
+// Prepares the X-Forwarded-For header for another forwarding hop by appending the previous sender's
+// IP address to the X-Forwarded-For chain.
+func AppendForwardedForHeader(req *http.Request) {
+ // Copied from net/http/httputil/reverseproxy.go:
+ if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
+ // If we aren't the first proxy retain prior
+ // X-Forwarded-For information as a comma+space
+ // separated list and fold multiple headers into one.
+ if prior, ok := req.Header["X-Forwarded-For"]; ok {
+ clientIP = strings.Join(prior, ", ") + ", " + clientIP
+ }
+ req.Header.Set("X-Forwarded-For", clientIP)
+ }
+}
+
+var defaultProxyFuncPointer = fmt.Sprintf("%p", http.ProxyFromEnvironment)
+
+// isDefault checks to see if the transportProxierFunc is pointing to the default one
+func isDefault(transportProxier func(*http.Request) (*url.URL, error)) bool {
+ transportProxierPointer := fmt.Sprintf("%p", transportProxier)
+ return transportProxierPointer == defaultProxyFuncPointer
+}
+
+// NewProxierWithNoProxyCIDR constructs a Proxier function that respects CIDRs in NO_PROXY and delegates if
+// no matching CIDRs are found
+func NewProxierWithNoProxyCIDR(delegate func(req *http.Request) (*url.URL, error)) func(req *http.Request) (*url.URL, error) {
+ // we wrap the default method, so we only need to perform our check if the NO_PROXY (or no_proxy) envvar has a CIDR in it
+ noProxyEnv := os.Getenv("NO_PROXY")
+ if noProxyEnv == "" {
+ noProxyEnv = os.Getenv("no_proxy")
+ }
+ noProxyRules := strings.Split(noProxyEnv, ",")
+
+ cidrs := []*net.IPNet{}
+ for _, noProxyRule := range noProxyRules {
+ _, cidr, _ := net.ParseCIDR(noProxyRule)
+ if cidr != nil {
+ cidrs = append(cidrs, cidr)
+ }
+ }
+
+ if len(cidrs) == 0 {
+ return delegate
+ }
+
+ return func(req *http.Request) (*url.URL, error) {
+ ip := net.ParseIP(req.URL.Hostname())
+ if ip == nil {
+ return delegate(req)
+ }
+
+ for _, cidr := range cidrs {
+ if cidr.Contains(ip) {
+ return nil, nil
+ }
+ }
+
+ return delegate(req)
+ }
+}
+
+// DialerFunc implements Dialer for the provided function.
+type DialerFunc func(req *http.Request) (net.Conn, error)
+
+func (fn DialerFunc) Dial(req *http.Request) (net.Conn, error) {
+ return fn(req)
+}
+
+// Dialer dials a host and writes a request to it.
+type Dialer interface {
+ // Dial connects to the host specified by req's URL, writes the request to the connection, and
+ // returns the opened net.Conn.
+ Dial(req *http.Request) (net.Conn, error)
+}
+
+// ConnectWithRedirects uses dialer to send req, following up to 10 redirects (relative to
+// originalLocation). It returns the opened net.Conn and the raw response bytes.
+// If requireSameHostRedirects is true, only redirects to the same host are permitted.
+func ConnectWithRedirects(originalMethod string, originalLocation *url.URL, header http.Header, originalBody io.Reader, dialer Dialer, requireSameHostRedirects bool) (net.Conn, []byte, error) {
+ const (
+ maxRedirects = 9 // Fail on the 10th redirect
+ maxResponseSize = 16384 // play it safe to allow the potential for lots of / large headers
+ )
+
+ var (
+ location = originalLocation
+ method = originalMethod
+ intermediateConn net.Conn
+ rawResponse = bytes.NewBuffer(make([]byte, 0, 256))
+ body = originalBody
+ )
+
+ defer func() {
+ if intermediateConn != nil {
+ intermediateConn.Close()
+ }
+ }()
+
+redirectLoop:
+ for redirects := 0; ; redirects++ {
+ if redirects > maxRedirects {
+ return nil, nil, fmt.Errorf("too many redirects (%d)", redirects)
+ }
+
+ req, err := http.NewRequest(method, location.String(), body)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ req.Header = header
+
+ intermediateConn, err = dialer.Dial(req)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Peek at the backend response.
+ rawResponse.Reset()
+ respReader := bufio.NewReader(io.TeeReader(
+ io.LimitReader(intermediateConn, maxResponseSize), // Don't read more than maxResponseSize bytes.
+ rawResponse)) // Save the raw response.
+ resp, err := http.ReadResponse(respReader, nil)
+ if err != nil {
+ // Unable to read the backend response; let the client handle it.
+ klog.Warningf("Error reading backend response: %v", err)
+ break redirectLoop
+ }
+
+ switch resp.StatusCode {
+ case http.StatusFound:
+ // Redirect, continue.
+ default:
+ // Don't redirect.
+ break redirectLoop
+ }
+
+ // Redirected requests switch to "GET" according to the HTTP spec:
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3
+ method = "GET"
+ // don't send a body when following redirects
+ body = nil
+
+ resp.Body.Close() // not used
+
+ // Prepare to follow the redirect.
+ redirectStr := resp.Header.Get("Location")
+ if redirectStr == "" {
+ return nil, nil, fmt.Errorf("%d response missing Location header", resp.StatusCode)
+ }
+ // We have to parse relative to the current location, NOT originalLocation. For example,
+ // if we request http://foo.com/a and get back "http://bar.com/b", the result should be
+ // http://bar.com/b. If we then make that request and get back a redirect to "/c", the result
+ // should be http://bar.com/c, not http://foo.com/c.
+ location, err = location.Parse(redirectStr)
+ if err != nil {
+ return nil, nil, fmt.Errorf("malformed Location header: %v", err)
+ }
+
+ // Only follow redirects to the same host. Otherwise, propagate the redirect response back.
+ if requireSameHostRedirects && location.Hostname() != originalLocation.Hostname() {
+ return nil, nil, fmt.Errorf("hostname mismatch: expected %s, found %s", originalLocation.Hostname(), location.Hostname())
+ }
+
+ // Reset the connection.
+ intermediateConn.Close()
+ intermediateConn = nil
+ }
+
+ connToReturn := intermediateConn
+ intermediateConn = nil // Don't close the connection when we return it.
+ return connToReturn, rawResponse.Bytes(), nil
+}
+
+// CloneRequest creates a shallow copy of the request along with a deep copy of the Headers.
+func CloneRequest(req *http.Request) *http.Request {
+ r := new(http.Request)
+
+ // shallow clone
+ *r = *req
+
+ // deep copy headers
+ r.Header = CloneHeader(req.Header)
+
+ return r
+}
+
+// CloneHeader creates a deep copy of an http.Header.
+func CloneHeader(in http.Header) http.Header {
+ out := make(http.Header, len(in))
+ for key, values := range in {
+ newValues := make([]string, len(values))
+ copy(newValues, values)
+ out[key] = newValues
+ }
+ return out
+}
+
+// WarningHeader contains a single RFC2616 14.46 warnings header
+type WarningHeader struct {
+ // Codeindicates the type of warning. 299 is a miscellaneous persistent warning
+ Code int
+ // Agent contains the name or pseudonym of the server adding the Warning header.
+ // A single "-" is recommended when agent is unknown.
+ Agent string
+ // Warning text
+ Text string
+}
+
+// ParseWarningHeaders extract RFC2616 14.46 warnings headers from the specified set of header values.
+// Multiple comma-separated warnings per header are supported.
+// If errors are encountered on a header, the remainder of that header are skipped and subsequent headers are parsed.
+// Returns successfully parsed warnings and any errors encountered.
+func ParseWarningHeaders(headers []string) ([]WarningHeader, []error) {
+ var (
+ results []WarningHeader
+ errs []error
+ )
+ for _, header := range headers {
+ for len(header) > 0 {
+ result, remainder, err := ParseWarningHeader(header)
+ if err != nil {
+ errs = append(errs, err)
+ break
+ }
+ results = append(results, result)
+ header = remainder
+ }
+ }
+ return results, errs
+}
+
+var (
+ codeMatcher = regexp.MustCompile(`^[0-9]{3}$`)
+ wordDecoder = &mime.WordDecoder{}
+)
+
+// ParseWarningHeader extracts one RFC2616 14.46 warning from the specified header,
+// returning an error if the header does not contain a correctly formatted warning.
+// Any remaining content in the header is returned.
+func ParseWarningHeader(header string) (result WarningHeader, remainder string, err error) {
+ // https://tools.ietf.org/html/rfc2616#section-14.46
+ // updated by
+ // https://tools.ietf.org/html/rfc7234#section-5.5
+ // https://tools.ietf.org/html/rfc7234#appendix-A
+ // Some requirements regarding production and processing of the Warning
+ // header fields have been relaxed, as it is not widely implemented.
+ // Furthermore, the Warning header field no longer uses RFC 2047
+ // encoding, nor does it allow multiple languages, as these aspects were
+ // not implemented.
+ //
+ // Format is one of:
+ // warn-code warn-agent "warn-text"
+ // warn-code warn-agent "warn-text" "warn-date"
+ //
+ // warn-code is a three digit number
+ // warn-agent is unquoted and contains no spaces
+ // warn-text is quoted with backslash escaping (RFC2047-encoded according to RFC2616, not encoded according to RFC7234)
+ // warn-date is optional, quoted, and in HTTP-date format (no embedded or escaped quotes)
+ //
+ // additional warnings can optionally be included in the same header by comma-separating them:
+ // warn-code warn-agent "warn-text" "warn-date"[, warn-code warn-agent "warn-text" "warn-date", ...]
+
+ // tolerate leading whitespace
+ header = strings.TrimSpace(header)
+
+ parts := strings.SplitN(header, " ", 3)
+ if len(parts) != 3 {
+ return WarningHeader{}, "", errors.New("invalid warning header: fewer than 3 segments")
+ }
+ code, agent, textDateRemainder := parts[0], parts[1], parts[2]
+
+ // verify code format
+ if !codeMatcher.Match([]byte(code)) {
+ return WarningHeader{}, "", errors.New("invalid warning header: code segment is not 3 digits between 100-299")
+ }
+ codeInt, _ := strconv.ParseInt(code, 10, 64)
+
+ // verify agent presence
+ if len(agent) == 0 {
+ return WarningHeader{}, "", errors.New("invalid warning header: empty agent segment")
+ }
+ if !utf8.ValidString(agent) || hasAnyRunes(agent, unicode.IsControl) {
+ return WarningHeader{}, "", errors.New("invalid warning header: invalid agent")
+ }
+
+ // verify textDateRemainder presence
+ if len(textDateRemainder) == 0 {
+ return WarningHeader{}, "", errors.New("invalid warning header: empty text segment")
+ }
+
+ // extract text
+ text, dateAndRemainder, err := parseQuotedString(textDateRemainder)
+ if err != nil {
+ return WarningHeader{}, "", fmt.Errorf("invalid warning header: %v", err)
+ }
+ // tolerate RFC2047-encoded text from warnings produced according to RFC2616
+ if decodedText, err := wordDecoder.DecodeHeader(text); err == nil {
+ text = decodedText
+ }
+ if !utf8.ValidString(text) || hasAnyRunes(text, unicode.IsControl) {
+ return WarningHeader{}, "", errors.New("invalid warning header: invalid text")
+ }
+ result = WarningHeader{Code: int(codeInt), Agent: agent, Text: text}
+
+ if len(dateAndRemainder) > 0 {
+ if dateAndRemainder[0] == '"' {
+ // consume date
+ foundEndQuote := false
+ for i := 1; i < len(dateAndRemainder); i++ {
+ if dateAndRemainder[i] == '"' {
+ foundEndQuote = true
+ remainder = strings.TrimSpace(dateAndRemainder[i+1:])
+ break
+ }
+ }
+ if !foundEndQuote {
+ return WarningHeader{}, "", errors.New("invalid warning header: unterminated date segment")
+ }
+ } else {
+ remainder = dateAndRemainder
+ }
+ }
+ if len(remainder) > 0 {
+ if remainder[0] == ',' {
+ // consume comma if present
+ remainder = strings.TrimSpace(remainder[1:])
+ } else {
+ return WarningHeader{}, "", errors.New("invalid warning header: unexpected token after warn-date")
+ }
+ }
+
+ return result, remainder, nil
+}
+
+func parseQuotedString(quotedString string) (string, string, error) {
+ if len(quotedString) == 0 {
+ return "", "", errors.New("invalid quoted string: 0-length")
+ }
+
+ if quotedString[0] != '"' {
+ return "", "", errors.New("invalid quoted string: missing initial quote")
+ }
+
+ quotedString = quotedString[1:]
+ var remainder string
+ escaping := false
+ closedQuote := false
+ result := &bytes.Buffer{}
+loop:
+ for i := 0; i < len(quotedString); i++ {
+ b := quotedString[i]
+ switch b {
+ case '"':
+ if escaping {
+ result.WriteByte(b)
+ escaping = false
+ } else {
+ closedQuote = true
+ remainder = strings.TrimSpace(quotedString[i+1:])
+ break loop
+ }
+ case '\\':
+ if escaping {
+ result.WriteByte(b)
+ escaping = false
+ } else {
+ escaping = true
+ }
+ default:
+ result.WriteByte(b)
+ escaping = false
+ }
+ }
+
+ if !closedQuote {
+ return "", "", errors.New("invalid quoted string: missing closing quote")
+ }
+ return result.String(), remainder, nil
+}
+
+func NewWarningHeader(code int, agent, text string) (string, error) {
+ if code < 0 || code > 999 {
+ return "", errors.New("code must be between 0 and 999")
+ }
+ if len(agent) == 0 {
+ agent = "-"
+ } else if !utf8.ValidString(agent) || strings.ContainsAny(agent, `\"`) || hasAnyRunes(agent, unicode.IsSpace, unicode.IsControl) {
+ return "", errors.New("agent must be valid UTF-8 and must not contain spaces, quotes, backslashes, or control characters")
+ }
+ if !utf8.ValidString(text) || hasAnyRunes(text, unicode.IsControl) {
+ return "", errors.New("text must be valid UTF-8 and must not contain control characters")
+ }
+ return fmt.Sprintf("%03d %s %s", code, agent, makeQuotedString(text)), nil
+}
+
+func hasAnyRunes(s string, runeCheckers ...func(rune) bool) bool {
+ for _, r := range s {
+ for _, checker := range runeCheckers {
+ if checker(r) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func makeQuotedString(s string) string {
+ result := &bytes.Buffer{}
+ // opening quote
+ result.WriteRune('"')
+ for _, c := range s {
+ switch c {
+ case '"', '\\':
+ // escape " and \
+ result.WriteRune('\\')
+ result.WriteRune(c)
+ default:
+ // write everything else as-is
+ result.WriteRune(c)
+ }
+ }
+ // closing quote
+ result.WriteRune('"')
+ return result.String()
+}