From 5bcd3043a32d8fdb9d15ea9d99f2757dd5f5c218 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:15:47 -0400 Subject: [PATCH 01/13] fix(cli): fail when buffered response output cannot be flushed --- cmd/kurl/main.go | 4 +++- cmd/kurl/output_error_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 cmd/kurl/output_error_test.go diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index 0b56c49..e8e0856 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -164,7 +164,9 @@ func runRequest(opts cliOptions) { fatal(err) } } - bw.Flush() + if err := bw.Flush(); err != nil { + fatal(err) + } } func handleSaveCommand(args []string) { diff --git a/cmd/kurl/output_error_test.go b/cmd/kurl/output_error_test.go new file mode 100644 index 0000000..36d89bb --- /dev/null +++ b/cmd/kurl/output_error_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestRequestReportsBufferedOutputFailure(t *testing.T) { + if url := os.Getenv("KURL_TEST_CLOSED_OUTPUT_URL"); url != "" { + if err := os.Stdout.Close(); err != nil { + t.Fatal(err) + } + runRequest(cliOptions{url: url, method: "GET", timeout: time.Second}) + os.Exit(0) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestRequestReportsBufferedOutputFailure$") + cmd.Env = append(os.Environ(), "KURL_TEST_CLOSED_OUTPUT_URL="+srv.URL) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("request reported success despite a closed output destination") + } + if !strings.Contains(string(out), "file already closed") { + t.Fatalf("unexpected failure: %s", out) + } +} From 2d388a201b05e38774f37464c2710d8691506bb5 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:16:35 -0400 Subject: [PATCH 02/13] fix(cli): preserve filters and HTTP3 in saved requests --- cmd/kurl/main.go | 64 ++++++++++++++++++++-------------- cmd/kurl/saved_options_test.go | 21 +++++++++++ 2 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 cmd/kurl/saved_options_test.go diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index e8e0856..ec72b2c 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -47,19 +47,23 @@ type cliOptions struct { } type savedRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Data string `json:"data,omitempty"` - Headers []string `json:"headers,omitempty"` - Timeout string `json:"timeout,omitempty"` - NoColor bool `json:"no_color,omitempty"` - HeadersOnly bool `json:"headers_only,omitempty"` - BodyOnly bool `json:"body_only,omitempty"` - Raw bool `json:"raw,omitempty"` - Verbose bool `json:"verbose,omitempty"` - Timing bool `json:"timing,omitempty"` - OutputPath string `json:"output_path,omitempty"` - Env string `json:"env,omitempty"` + FilterQuery string `json:"filter_query,omitempty"` + FilterKeys string `json:"filter_keys,omitempty"` + FilterFlatten bool `json:"filter_flatten,omitempty"` + HTTP3 bool `json:"http3,omitempty"` + Method string `json:"method"` + URL string `json:"url"` + Data string `json:"data,omitempty"` + Headers []string `json:"headers,omitempty"` + Timeout string `json:"timeout,omitempty"` + NoColor bool `json:"no_color,omitempty"` + HeadersOnly bool `json:"headers_only,omitempty"` + BodyOnly bool `json:"body_only,omitempty"` + Raw bool `json:"raw,omitempty"` + Verbose bool `json:"verbose,omitempty"` + Timing bool `json:"timing,omitempty"` + OutputPath string `json:"output_path,omitempty"` + Env string `json:"env,omitempty"` } func main() { @@ -406,19 +410,23 @@ func saveRequestLocally(name string, opts cliOptions) error { } req := savedRequest{ - Method: opts.method, - URL: opts.url, - Data: opts.data, - Headers: opts.headers, - Timeout: opts.timeout.String(), - NoColor: opts.noColor, - HeadersOnly: opts.headersOnly, - BodyOnly: opts.bodyOnly, - Raw: opts.raw, - Verbose: opts.verbose, - Timing: opts.timing, - OutputPath: opts.outputPath, - Env: opts.env, + FilterQuery: opts.filterQuery, + FilterKeys: opts.filterKeys, + FilterFlatten: opts.filterFlatten, + HTTP3: opts.http3, + Method: opts.method, + URL: opts.url, + Data: opts.data, + Headers: opts.headers, + Timeout: opts.timeout.String(), + NoColor: opts.noColor, + HeadersOnly: opts.headersOnly, + BodyOnly: opts.bodyOnly, + Raw: opts.raw, + Verbose: opts.verbose, + Timing: opts.timing, + OutputPath: opts.outputPath, + Env: opts.env, } data, err := json.MarshalIndent(req, "", " ") @@ -482,6 +490,10 @@ func loadRequestLocally(name string) (cliOptions, error) { options.timing = req.Timing options.outputPath = req.OutputPath options.env = req.Env + options.filterQuery = req.FilterQuery + options.filterKeys = req.FilterKeys + options.filterFlatten = req.FilterFlatten + options.http3 = req.HTTP3 return options, nil } diff --git a/cmd/kurl/saved_options_test.go b/cmd/kurl/saved_options_test.go new file mode 100644 index 0000000..0a848be --- /dev/null +++ b/cmd/kurl/saved_options_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" + "time" +) + +func TestSavedRequestPreservesFilteringAndProtocol(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + original := cliOptions{method: "GET", url: "https://example.com", timeout: time.Second, filterQuery: ".items", filterKeys: "id,name", filterFlatten: true, http3: true} + if err := saveRequestLocally("filtered", original); err != nil { + t.Fatal(err) + } + got, err := loadRequestLocally("filtered") + if err != nil { + t.Fatal(err) + } + if got.filterQuery != original.filterQuery || got.filterKeys != original.filterKeys || !got.filterFlatten || !got.http3 { + t.Fatalf("saved options lost: query=%q keys=%q flatten=%v http3=%v", got.filterQuery, got.filterKeys, got.filterFlatten, got.http3) + } +} From fcb8234b58864b6c94ed06a11d68a2c3bf0ca8a8 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:10:27 -0400 Subject: [PATCH 03/13] Reject invalid GraphQL and SSE arguments before sending requests --- cmd/kurl/main.go | 14 +++++-- cmd/kurl/subcommand_arguments_test.go | 57 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 cmd/kurl/subcommand_arguments_test.go diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index ec72b2c..ba2f84e 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -282,9 +282,12 @@ func handleGraphQLCommand(args []string) { case arg == "-v" || arg == "--verbose": verbose = true case !strings.HasPrefix(arg, "-"): - if targetURL == "" { - targetURL = arg + if targetURL != "" { + fatal(fmt.Errorf("unexpected argument %q", arg)) } + targetURL = arg + default: + fatal(fmt.Errorf("unknown option %q", arg)) } } @@ -363,9 +366,12 @@ func handleSSECommand(args []string) { case arg == "--no-color": noColor = true case !strings.HasPrefix(arg, "-"): - if targetURL == "" { - targetURL = arg + if targetURL != "" { + fatal(fmt.Errorf("unexpected argument %q", arg)) } + targetURL = arg + default: + fatal(fmt.Errorf("unknown option %q", arg)) } } diff --git a/cmd/kurl/subcommand_arguments_test.go b/cmd/kurl/subcommand_arguments_test.go new file mode 100644 index 0000000..cc46bfc --- /dev/null +++ b/cmd/kurl/subcommand_arguments_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync/atomic" + "testing" +) + +func TestSubcommandsRejectUnexpectedArguments(t *testing.T) { + if os.Getenv("KURL_ARGUMENT_TEST") == "1" { + for i, arg := range os.Args { + if arg == "--" { + if os.Args[i+1] == "graphql" { + handleGraphQLCommand(os.Args[i+2:]) + } else { + handleSSECommand(os.Args[i+2:]) + } + return + } + } + t.Fatal("missing child arguments") + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer server.Close() + for _, command := range []string{"graphql", "sse"} { + for _, unexpected := range []string{"--misspelled-option", "extra-endpoint"} { + t.Run(command+"/"+unexpected, func(t *testing.T) { + args := []string{"-test.run=^TestSubcommandsRejectUnexpectedArguments$", "--", command, server.URL} + if command == "graphql" { + args = append(args, "--query", "{ id }") + } + args = append(args, unexpected) + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), "KURL_ARGUMENT_TEST=1") + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("unexpected argument succeeded: %s", output) + } + if !strings.Contains(string(output), unexpected) { + t.Fatalf("error omitted argument: %s", output) + } + }) + } + } + if got := requests.Load(); got != 0 { + t.Errorf("invalid commands sent %d requests", got) + } +} From 5b84ea3bff7ac9b55980b48ccb3f4bcd6722952c Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:11:19 -0400 Subject: [PATCH 04/13] Report GraphQL output flush errors to the caller --- cmd/kurl/main.go | 4 +++- cmd/kurl/output_error_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index ba2f84e..2d889a8 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -319,7 +319,9 @@ func handleGraphQLCommand(args []string) { bw.Flush() fatal(err) } - bw.Flush() + if err := bw.Flush(); err != nil { + fatal(err) + } } func handleSSECommand(args []string) { diff --git a/cmd/kurl/output_error_test.go b/cmd/kurl/output_error_test.go index 36d89bb..a2b836d 100644 --- a/cmd/kurl/output_error_test.go +++ b/cmd/kurl/output_error_test.go @@ -33,3 +33,27 @@ func TestRequestReportsBufferedOutputFailure(t *testing.T) { t.Fatalf("unexpected failure: %s", out) } } + +func TestGraphQLReportsBufferedOutputFailure(t *testing.T) { + if url := os.Getenv("KURL_TEST_GRAPHQL_CLOSED_OUTPUT_URL"); url != "" { + if err := os.Stdout.Close(); err != nil { + t.Fatal(err) + } + handleGraphQLCommand([]string{url, "--query", "{ id }"}) + os.Exit(0) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":1}}`)) + })) + defer srv.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestGraphQLReportsBufferedOutputFailure$") + cmd.Env = append(os.Environ(), "KURL_TEST_GRAPHQL_CLOSED_OUTPUT_URL="+srv.URL) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("GraphQL succeeded despite closed output") + } + if !strings.Contains(string(out), "file already closed") { + t.Fatalf("unexpected error: %s", out) + } +} From 5298c5deef695f4de418f33ad1fe53429481f040 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:31:40 -0400 Subject: [PATCH 05/13] Accept WebSocket input up to the protocol payload limit --- cmd/kurl/websocket.go | 3 ++- cmd/kurl/websocket_input_test.go | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 cmd/kurl/websocket_input_test.go diff --git a/cmd/kurl/websocket.go b/cmd/kurl/websocket.go index b1de546..815ded2 100644 --- a/cmd/kurl/websocket.go +++ b/cmd/kurl/websocket.go @@ -91,6 +91,7 @@ func runWebSocket(opts cliOptions) { // Send loop scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 4096), websocket.DefaultMaxPayloadBytes+1) for scanner.Scan() { text := scanner.Text() if text == "" { @@ -108,7 +109,7 @@ func runWebSocket(opts cliOptions) { } if err := scanner.Err(); err != nil { - fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) + fatal(fmt.Errorf("error reading input: %w", err)) } } diff --git a/cmd/kurl/websocket_input_test.go b/cmd/kurl/websocket_input_test.go new file mode 100644 index 0000000..e61d8d2 --- /dev/null +++ b/cmd/kurl/websocket_input_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "golang.org/x/net/websocket" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestWebSocketSendsInputAboveScannerDefault(t *testing.T) { + if url := os.Getenv("KURL_WS_INPUT_TEST"); url != "" { + runWebSocket(cliOptions{url: url, noColor: true, timeout: time.Second}) + os.Exit(0) + } + received := make(chan string, 1) + server := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) { + var message string + _ = websocket.Message.Receive(ws, &message) + received <- message + })) + defer server.Close() + payload := strings.Repeat("x", 70*1024) + cmd := exec.Command(os.Args[0], "-test.run=^TestWebSocketSendsInputAboveScannerDefault$") + cmd.Env = append(os.Environ(), "KURL_WS_INPUT_TEST=ws"+strings.TrimPrefix(server.URL, "http")) + cmd.Stdin = strings.NewReader(payload + "\n") + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + select { + case got := <-received: + if got != payload { + t.Fatalf("received %d bytes, want %d", len(got), len(payload)) + } + case <-time.After(time.Second): + t.Fatal("server did not receive input") + } +} From 8ec0d6772cc3fb2996d9d707eb4f89344cb968af Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:32:53 -0400 Subject: [PATCH 06/13] Apply configured deadlines to WebSocket handshakes --- cmd/kurl/websocket.go | 9 ++++++++- cmd/kurl/websocket_timeout_test.go | 31 ++++++++++++++++++++++++++++++ docs/USAGE.md | 2 ++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 cmd/kurl/websocket_timeout_test.go diff --git a/cmd/kurl/websocket.go b/cmd/kurl/websocket.go index 815ded2..419e96b 100644 --- a/cmd/kurl/websocket.go +++ b/cmd/kurl/websocket.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -49,7 +50,13 @@ func runWebSocket(opts cliOptions) { config.Header.Set("Accept", "*/*") } - ws, err := websocket.DialConfig(config) + ctx := context.Background() + cancel := func() {} + if opts.timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, opts.timeout) + } + ws, err := config.DialContext(ctx) + cancel() // The connection deadline ends once the handshake finishes. if err != nil { fatal(fmt.Errorf("failed to connect to websocket: %w", err)) } diff --git a/cmd/kurl/websocket_timeout_test.go b/cmd/kurl/websocket_timeout_test.go new file mode 100644 index 0000000..31908b3 --- /dev/null +++ b/cmd/kurl/websocket_timeout_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestWebSocketHandshakeHonorsTimeout(t *testing.T) { + if url := os.Getenv("KURL_WS_TIMEOUT_TEST"); url != "" { + runWebSocket(cliOptions{url: url, timeout: 40 * time.Millisecond}) + os.Exit(0) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWebSocketHandshakeHonorsTimeout$") + cmd.Env = append(os.Environ(), "KURL_WS_TIMEOUT_TEST=ws"+strings.TrimPrefix(server.URL, "http")) + out, err := cmd.CombinedOutput() + if err == nil || ctx.Err() != nil || !strings.Contains(string(out), "deadline exceeded") { + t.Fatalf("configured handshake timeout was not reported: err=%v context=%v output=%s", err, ctx.Err(), out) + } +} diff --git a/docs/USAGE.md b/docs/USAGE.md index aa62386..80856b6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -184,3 +184,5 @@ kurl "https://api.example.com/?search=query" alias kurl="noglob kurl" ``` After saving, run `source ~/.zshrc`. You can now pass raw URLs to `kurl` without ever needing quotes again! + +For WebSocket URLs, `--timeout` bounds connection establishment and the HTTP upgrade handshake. Once connected, the interactive session remains open until input ends or the peer disconnects. Use `--timeout 0` to disable the connection deadline. From ff1e5f17cc26d5f5ef8fc150db50a67838586e50 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:34:32 -0400 Subject: [PATCH 07/13] Recognize case-insensitive WebSocket URI schemes --- cmd/kurl/main.go | 2 +- cmd/kurl/websocket.go | 9 ++++++++- cmd/kurl/websocket_input_test.go | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index 2d889a8..94cba39 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -115,7 +115,7 @@ func runRequest(opts cliOptions) { fatal(err) } - if strings.HasPrefix(opts.url, "ws://") || strings.HasPrefix(opts.url, "wss://") { + if isWebSocketURL(opts.url) { runWebSocket(opts) return } diff --git a/cmd/kurl/websocket.go b/cmd/kurl/websocket.go index 419e96b..29e88ab 100644 --- a/cmd/kurl/websocket.go +++ b/cmd/kurl/websocket.go @@ -20,10 +20,12 @@ func runWebSocket(opts cliOptions) { useColor := color.AutoEnabled(os.Stdout) && !opts.noColor // Ensure the URL has ws:// or wss:// scheme - if !strings.HasPrefix(opts.url, "ws://") && !strings.HasPrefix(opts.url, "wss://") { + if !isWebSocketURL(opts.url) { fatal(fmt.Errorf("invalid websocket url %q (must start with ws:// or wss://)", opts.url)) } + scheme, rest, _ := strings.Cut(opts.url, "://") + opts.url = strings.ToLower(scheme) + "://" + rest config, err := websocket.NewConfig(opts.url, opts.url) if err != nil { fatal(fmt.Errorf("failed to create websocket configuration: %w", err)) @@ -134,3 +136,8 @@ func boxTop(enabled bool, title string) string { func boxBottom(enabled bool, width int) string { return color.Border(enabled, "โ””"+strings.Repeat("โ”€", width)+"โ”˜") } + +func isWebSocketURL(raw string) bool { + scheme, _, ok := strings.Cut(raw, "://") + return ok && (strings.EqualFold(scheme, "ws") || strings.EqualFold(scheme, "wss")) +} diff --git a/cmd/kurl/websocket_input_test.go b/cmd/kurl/websocket_input_test.go index e61d8d2..ff208c1 100644 --- a/cmd/kurl/websocket_input_test.go +++ b/cmd/kurl/websocket_input_test.go @@ -24,7 +24,7 @@ func TestWebSocketSendsInputAboveScannerDefault(t *testing.T) { defer server.Close() payload := strings.Repeat("x", 70*1024) cmd := exec.Command(os.Args[0], "-test.run=^TestWebSocketSendsInputAboveScannerDefault$") - cmd.Env = append(os.Environ(), "KURL_WS_INPUT_TEST=ws"+strings.TrimPrefix(server.URL, "http")) + cmd.Env = append(os.Environ(), "KURL_WS_INPUT_TEST=WS"+strings.TrimPrefix(server.URL, "http")) cmd.Stdin = strings.NewReader(payload + "\n") if err := cmd.Run(); err != nil { t.Fatal(err) @@ -38,3 +38,16 @@ func TestWebSocketSendsInputAboveScannerDefault(t *testing.T) { t.Fatal("server did not receive input") } } + +func TestWebSocketSchemeCase(t *testing.T) { + for _, raw := range []string{"ws://example.test", "WS://example.test", "wSs://example.test"} { + if !isWebSocketURL(raw) { + t.Errorf("missed WebSocket URL %q", raw) + } + } + for _, raw := range []string{"http://example.test", "ws-not-a-scheme://example.test"} { + if isWebSocketURL(raw) { + t.Errorf("misclassified %q", raw) + } + } +} From b6de0776d35034a971e0ad5ee78d7dae2d7ebf39 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:37:14 -0400 Subject: [PATCH 08/13] Reject invalid timeout values in saved requests --- cmd/kurl/main.go | 5 ++++- cmd/kurl/saved_timeout_test.go | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 cmd/kurl/saved_timeout_test.go diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index 94cba39..6f4c2c3 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -479,7 +479,10 @@ func loadRequestLocally(name string) (cliOptions, error) { if req.Timeout != "" { timeout, err = time.ParseDuration(req.Timeout) if err != nil { - timeout = 30 * time.Second + return options, fmt.Errorf("invalid saved request timeout %q: %w", req.Timeout, err) + } + if timeout < 0 { + return options, fmt.Errorf("invalid saved request timeout %q: must not be negative", req.Timeout) } } else { timeout = 30 * time.Second diff --git a/cmd/kurl/saved_timeout_test.go b/cmd/kurl/saved_timeout_test.go new file mode 100644 index 0000000..0e74e0f --- /dev/null +++ b/cmd/kurl/saved_timeout_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSavedRequestRejectsInvalidTimeout(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + dir := filepath.Join(home, ".kurl", "requests") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + for _, timeout := range []string{"-1s", "not-a-duration", "9999999999999999999999s"} { + data, err := json.Marshal(savedRequest{URL: "https://example.test", Timeout: timeout}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.json"), data, 0600); err != nil { + t.Fatal(err) + } + if _, err := loadRequestLocally("bad"); err == nil { + t.Errorf("accepted timeout %q", timeout) + } + } + for _, timeout := range []time.Duration{0, time.Second} { + if err := saveRequestLocally("valid", cliOptions{url: "https://example.test", timeout: timeout}); err != nil { + t.Fatal(err) + } + got, err := loadRequestLocally("valid") + if err != nil || got.timeout != timeout { + t.Fatalf("valid timeout %v: %+v, %v", timeout, got, err) + } + } +} From 3f87380a2cfc731267cddf4d2d4aec8fd2c64708 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:47:25 -0400 Subject: [PATCH 09/13] Apply import grouping required by lint --- cmd/kurl/websocket_input_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/kurl/websocket_input_test.go b/cmd/kurl/websocket_input_test.go index ff208c1..c7f3d65 100644 --- a/cmd/kurl/websocket_input_test.go +++ b/cmd/kurl/websocket_input_test.go @@ -1,13 +1,14 @@ package main import ( - "golang.org/x/net/websocket" "net/http/httptest" "os" "os/exec" "strings" "testing" "time" + + "golang.org/x/net/websocket" ) func TestWebSocketSendsInputAboveScannerDefault(t *testing.T) { From ce329b901c6791c93248e9fa9480b321a4384ad9 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:59:27 -0400 Subject: [PATCH 10/13] Return failure when WebSocket message reception fails --- cmd/kurl/websocket.go | 2 +- cmd/kurl/websocket_receive_failure_test.go | 57 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 cmd/kurl/websocket_receive_failure_test.go diff --git a/cmd/kurl/websocket.go b/cmd/kurl/websocket.go index 29e88ab..d304aa0 100644 --- a/cmd/kurl/websocket.go +++ b/cmd/kurl/websocket.go @@ -79,7 +79,7 @@ func runWebSocket(opts cliOptions) { if err == io.EOF { fmt.Fprintln(os.Stdout, color.Wrap(useColor, color.Bold+color.Red, "\nDisconnected by remote host.")) } else if !strings.Contains(err.Error(), "use of closed network connection") { - fmt.Fprintf(os.Stderr, "\nError receiving: %v\n", err) + fatal(fmt.Errorf("error receiving WebSocket message: %w", err)) } os.Exit(0) } diff --git a/cmd/kurl/websocket_receive_failure_test.go b/cmd/kurl/websocket_receive_failure_test.go new file mode 100644 index 0000000..7cf7cbe --- /dev/null +++ b/cmd/kurl/websocket_receive_failure_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestWebSocketReceiveFailureExitsNonzero(t *testing.T) { + if target := os.Getenv("KURL_WS_BROKEN_FRAME_TEST"); target != "" { + runWebSocket(cliOptions{url: target, timeout: time.Second, noColor: true}) + os.Exit(0) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + sum := sha1.Sum([]byte(r.Header.Get("Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + _, _ = fmt.Fprintf(buffer, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", base64.StdEncoding.EncodeToString(sum[:])) + // Advertise a 64 MiB payload, exceeding the receive limit without allocating it. + _, _ = buffer.Write([]byte{0x81, 127, 0, 0, 0, 0, 4, 0, 0, 0}) + _ = buffer.Flush() + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWebSocketReceiveFailureExitsNonzero$") + cmd.Env = append(os.Environ(), "KURL_WS_BROKEN_FRAME_TEST=ws"+strings.TrimPrefix(server.URL, "http")) + input, writer, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatal(pipeErr) + } + defer input.Close() + defer writer.Close() + cmd.Stdin = input + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("client hung on receive failure") + } + if err == nil { + t.Fatalf("broken frame reported success: %s", output) + } + if !strings.Contains(string(output), "frame payload size exceeds limit") { + t.Fatalf("missing receive error: %s", output) + } +} From 65b15abdcadd9f5ebf1037e5d4719559de92f567 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:00:35 -0400 Subject: [PATCH 11/13] Make best-effort test resource cleanup explicit --- cmd/kurl/websocket_receive_failure_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/kurl/websocket_receive_failure_test.go b/cmd/kurl/websocket_receive_failure_test.go index 7cf7cbe..070a528 100644 --- a/cmd/kurl/websocket_receive_failure_test.go +++ b/cmd/kurl/websocket_receive_failure_test.go @@ -25,7 +25,7 @@ func TestWebSocketReceiveFailureExitsNonzero(t *testing.T) { t.Error(err) return } - defer conn.Close() + defer func() { _ = conn.Close() }() sum := sha1.Sum([]byte(r.Header.Get("Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) _, _ = fmt.Fprintf(buffer, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", base64.StdEncoding.EncodeToString(sum[:])) // Advertise a 64 MiB payload, exceeding the receive limit without allocating it. @@ -41,8 +41,8 @@ func TestWebSocketReceiveFailureExitsNonzero(t *testing.T) { if pipeErr != nil { t.Fatal(pipeErr) } - defer input.Close() - defer writer.Close() + defer func() { _ = input.Close() }() + defer func() { _ = writer.Close() }() cmd.Stdin = input output, err := cmd.CombinedOutput() if ctx.Err() != nil { From 80b710f44e98ed89924775694b749f1a2002f8b0 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:08:06 -0400 Subject: [PATCH 12/13] Report WebSocket output write failures --- cmd/kurl/websocket.go | 24 ++++++++++++---- cmd/kurl/websocket_output_failure_test.go | 34 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 cmd/kurl/websocket_output_failure_test.go diff --git a/cmd/kurl/websocket.go b/cmd/kurl/websocket.go index d304aa0..0f076ac 100644 --- a/cmd/kurl/websocket.go +++ b/cmd/kurl/websocket.go @@ -66,9 +66,15 @@ func runWebSocket(opts cliOptions) { // Print connection header statusLine := fmt.Sprintf("kurl ยท WebSocket Connected to %s", opts.url) - fmt.Fprintln(os.Stdout, boxTop(useColor, statusLine)) - fmt.Fprintln(os.Stdout, boxBottom(useColor, len(statusLine)+4)) - fmt.Fprintln(os.Stdout, color.Wrap(useColor, color.Dim, "Type messages and press Enter to send. Press Ctrl+C to exit.\n")) + if _, err := fmt.Fprintln(os.Stdout, boxTop(useColor, statusLine)); err != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", err)) + } + if _, err := fmt.Fprintln(os.Stdout, boxBottom(useColor, len(statusLine)+4)); err != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", err)) + } + if _, err := fmt.Fprintln(os.Stdout, color.Wrap(useColor, color.Dim, "Type messages and press Enter to send. Press Ctrl+C to exit.\n")); err != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", err)) + } // Receive goroutine go func() { @@ -77,7 +83,9 @@ func runWebSocket(opts cliOptions) { err := websocket.Message.Receive(ws, &msg) if err != nil { if err == io.EOF { - fmt.Fprintln(os.Stdout, color.Wrap(useColor, color.Bold+color.Red, "\nDisconnected by remote host.")) + if _, writeErr := fmt.Fprintln(os.Stdout, color.Wrap(useColor, color.Bold+color.Red, "\nDisconnected by remote host.")); writeErr != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", writeErr)) + } } else if !strings.Contains(err.Error(), "use of closed network connection") { fatal(fmt.Errorf("error receiving WebSocket message: %w", err)) } @@ -94,7 +102,9 @@ func runWebSocket(opts cliOptions) { } prefix := color.Wrap(useColor, color.Bold+color.Green, "[RECV] <") - fmt.Fprintf(os.Stdout, "%s %s\n", prefix, printedMsg) + if _, err := fmt.Fprintf(os.Stdout, "%s %s\n", prefix, printedMsg); err != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", err)) + } } }() @@ -114,7 +124,9 @@ func runWebSocket(opts cliOptions) { } prefix := color.Wrap(useColor, color.Bold+color.Cyan, "[SEND] >") - fmt.Fprintf(os.Stdout, "%s %s\n", prefix, text) + if _, err := fmt.Fprintf(os.Stdout, "%s %s\n", prefix, text); err != nil { + fatal(fmt.Errorf("error writing WebSocket output: %w", err)) + } } if err := scanner.Err(); err != nil { diff --git a/cmd/kurl/websocket_output_failure_test.go b/cmd/kurl/websocket_output_failure_test.go new file mode 100644 index 0000000..5722eec --- /dev/null +++ b/cmd/kurl/websocket_output_failure_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "io" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" + + "golang.org/x/net/websocket" +) + +func TestWebSocketReportsClosedOutput(t *testing.T) { + if target := os.Getenv("KURL_WS_CLOSED_OUTPUT_TEST"); target != "" { + if err := os.Stdout.Close(); err != nil { + t.Fatal(err) + } + runWebSocket(cliOptions{url: target, timeout: time.Second, noColor: true}) + os.Exit(0) + } + server := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) { _, _ = io.Copy(io.Discard, ws) })) + defer server.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestWebSocketReportsClosedOutput$") + cmd.Env = append(os.Environ(), "KURL_WS_CLOSED_OUTPUT_TEST=ws"+strings.TrimPrefix(server.URL, "http")) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("closed output reported success: %s", output) + } + if !strings.Contains(string(output), "file already closed") { + t.Fatalf("unexpected failure: %s", output) + } +} From 97f9ae151a933bb7f965731b9fad3ecb7d5a3f1e Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:17:27 -0400 Subject: [PATCH 13/13] Close unread response bodies after headers-only requests --- cmd/kurl/main.go | 2 ++ cmd/kurl/response_close_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 cmd/kurl/response_close_test.go diff --git a/cmd/kurl/main.go b/cmd/kurl/main.go index 6f4c2c3..d4b507d 100644 --- a/cmd/kurl/main.go +++ b/cmd/kurl/main.go @@ -137,6 +137,8 @@ func runRequest(opts cliOptions) { fatal(err) } + defer func() { _ = result.Response.Body.Close() }() + printerOptions := printer.Options{ Color: useColor, Raw: opts.raw, diff --git a/cmd/kurl/response_close_test.go b/cmd/kurl/response_close_test.go new file mode 100644 index 0000000..0a63771 --- /dev/null +++ b/cmd/kurl/response_close_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestHeadersOnlyClosesUnreadResponse(t *testing.T) { + disconnected := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1000") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + select { + case <-r.Context().Done(): + close(disconnected) + case <-release: + } + })) + defer server.Close() + defer close(release) + runRequest(cliOptions{url: server.URL, method: "GET", headersOnly: true, noColor: true}) + select { + case <-disconnected: + case <-time.After(time.Second): + t.Fatal("headers-only request returned without closing its unread response") + } +}