diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 322e003..68aa6b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ permissions: contents: read env: - SPEAKEASY_VERSION: v2.959.0 + SPEAKEASY_VERSION: v2.959.1 jobs: non-live-validation: diff --git a/internal/client/client.go b/internal/client/client.go index 710fba8..7ace0ab 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -79,10 +79,39 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.Gemini httpClient = testClient } httpClient = WrapClientForDiagnostics(cmd, httpClient) + if hdrs, _ := flagutil.GetStringArrayFlag(cmd, "header"); len(hdrs) > 0 { + headers, err := flagutil.ParseHeaderFlags(hdrs) + if err != nil { + return nil, err + } + if repeated := flagutil.RepeatedHeaders(headers); len(repeated) > 0 { + httpClient = &repeatedHeaderClient{next: httpClient, headers: repeated} + } + } sdkOpts = append(sdkOpts, sdk.WithClient(httpClient)) return sdk.New(sdkOpts...), nil } +// repeatedHeaderClient sends every value of a header repeated with --header on +// requests tagged with flagutil.RepeatedHeadersMarker. +// operations.WithSetHeaders carries only one value per header. +type repeatedHeaderClient struct { + next HTTPClient + headers http.Header +} + +func (c *repeatedHeaderClient) Do(req *http.Request) (*http.Response, error) { + if req.Header.Get(flagutil.RepeatedHeadersMarker) == "" { + return c.next.Do(req) + } + req = req.Clone(req.Context()) + req.Header.Del(flagutil.RepeatedHeadersMarker) + for name, values := range c.headers { + req.Header[name] = append([]string(nil), values...) + } + return c.next.Do(req) +} + // SameOriginRedirect refuses redirects to another scheme or host, and // redirects that change the request method: net/http forwards custom // credential headers (API keys) across origins and turns a POST answered with diff --git a/internal/flagutil/flags.go b/internal/flagutil/flags.go index 0946002..0a35119 100644 --- a/internal/flagutil/flags.go +++ b/internal/flagutil/flags.go @@ -24,6 +24,7 @@ import ( "fmt" "io" "net" + "net/http" "net/url" "os" "path/filepath" @@ -611,6 +612,72 @@ func (e serverURLValidationError) Error() string { return fmt.Sprintf("invalid --server-url %q: %v", e.value, e.error) } +// ParseHeaderFlags parses "Name: value" --header values into canonical header +// names, keeping repeated values in the order given. +func ParseHeaderFlags(values []string) (http.Header, error) { + headers := http.Header{} + for _, h := range values { + k, v, ok := strings.Cut(h, ":") + if !ok { + return nil, WithCLIValidation(fmt.Errorf("invalid --header value: expected \"Key: Value\" with a colon after the header name")) + } + name, value := strings.TrimSpace(k), strings.TrimSpace(v) + if !validHeaderName(name) { + return nil, WithCLIValidation(fmt.Errorf("invalid header name %q: use letters, digits and !#$%%&'*+-.^_`|~", name)) + } + if !validHeaderValue(value) { + return nil, WithCLIValidation(fmt.Errorf("invalid value for header %q: control characters such as CR or LF are not allowed", name)) + } + if http.CanonicalHeaderKey(name) == RepeatedHeadersMarker { + return nil, WithCLIValidation(fmt.Errorf("invalid header name %q: reserved for internal use", name)) + } + headers.Add(name, value) + } + return headers, nil +} + +// RepeatedHeadersMarker tags the requests an operation builds itself, so the +// remaining values of a repeated --header reach only those requests and not +// the requests SDK hooks send through the same client, such as OAuth token +// calls. +const RepeatedHeadersMarker = "X-Cli-Repeated-Headers" + +// RepeatedHeaders returns the headers given more than once. +func RepeatedHeaders(headers http.Header) http.Header { + repeated := http.Header{} + for name, values := range headers { + if len(values) > 1 { + repeated[name] = values + } + } + return repeated +} + +func validHeaderName(name string) bool { + if name == "" { + return false + } + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + return true +} + +func validHeaderValue(value string) bool { + for i := 0; i < len(value); i++ { + if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f { + return false + } + } + return true +} + func ValidateServerURL(value string) error { u, err := url.Parse(value) if err != nil { diff --git a/internal/output/output.go b/internal/output/output.go index 927c39e..ac63527 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -364,18 +364,23 @@ func renderPathEnvelope(cmd *cobra.Command, absPath string, envelope map[string] } // PrepareCallOpts builds common SDK call options from CLI flags. -// Parses --header flags into operations.WithSetHeaders options. +// Parses --header flags into operations.WithSetHeaders options carrying each +// header's first value; client.NewClient sends the remaining values of a +// repeated header. // Server resolution and skip-deserialization are handled per-operation. func PrepareCallOpts(cmd *cobra.Command) ([]operations.Option, error) { var opts []operations.Option if hdrs, _ := flagutil.GetStringArrayFlag(cmd, "header"); len(hdrs) > 0 { - headerMap := make(map[string]string, len(hdrs)) - for _, h := range hdrs { - k, v, ok := strings.Cut(h, ":") - if !ok { - return nil, flagutil.WithCLIValidation(fmt.Errorf("invalid header format %q: expected \"Key: Value\"", h)) - } - headerMap[strings.TrimSpace(k)] = strings.TrimSpace(v) + headers, err := flagutil.ParseHeaderFlags(hdrs) + if err != nil { + return nil, err + } + headerMap := make(map[string]string, len(headers)) + for name, values := range headers { + headerMap[name] = values[0] + } + if len(flagutil.RepeatedHeaders(headers)) > 0 { + headerMap[flagutil.RepeatedHeadersMarker] = "1" } opts = append(opts, operations.WithSetHeaders(headerMap)) }