Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions internal/curl/exit_codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2026 Google LLC
//
// 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
//
// https://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 curl

// ExitCodeName returns the human readable name for a curl exit code.
func ExitCodeName(code int) string {
if code == 0 {
return "OK"
}
if name, ok := exitCodeNames[code]; ok {
return name
}
return "UNKNOWN_ERROR"
}

var exitCodeNames = map[int]string{
1: "CURLE_UNSUPPORTED_PROTOCOL",
2: "CURLE_FAILED_INIT",
3: "CURLE_URL_MALFORMAT",
4: "CURLE_NOT_BUILT_IN",
5: "CURLE_COULDNT_RESOLVE_PROXY",
6: "CURLE_COULDNT_RESOLVE_HOST",
7: "CURLE_COULDNT_CONNECT",
8: "CURLE_WEIRD_SERVER_REPLY",
9: "CURLE_REMOTE_ACCESS_DENIED",
11: "CURLE_FTP_WEIRD_PASV_REPLY",
13: "CURLE_FTP_WEIRD_227_FORMAT",
14: "CURLE_FTP_CANT_GET_HOST",
15: "CURLE_FTP_CANT_RECONNECT",
17: "CURLE_FTP_COULDNT_SET_TYPE",
18: "CURLE_PARTIAL_FILE",
19: "CURLE_FTP_COULDNT_RETR_FILE",
21: "CURLE_QUOTE_ERROR",
22: "CURLE_HTTP_RETURNED_ERROR",
23: "CURLE_WRITE_ERROR",
25: "CURLE_UPLOAD_FAILED",
26: "CURLE_READ_ERROR",
27: "CURLE_OUT_OF_MEMORY",
28: "CURLE_OPERATION_TIMEDOUT",
30: "CURLE_FTP_PORT_FAILED",
31: "CURLE_FTP_COULDNT_USE_REST",
33: "CURLE_RANGE_ERROR",
34: "CURLE_HTTP_POST_ERROR",
35: "CURLE_SSL_CONNECT_ERROR",
36: "CURLE_BAD_DOWNLOAD_RESUME",
37: "CURLE_FILE_COULDNT_READ_FILE",
38: "CURLE_LDAP_CANNOT_BIND",
39: "CURLE_LDAP_SEARCH_FAILED",
41: "CURLE_FUNCTION_NOT_FOUND",
42: "CURLE_ABORTED_BY_CALLBACK",
43: "CURLE_BAD_FUNCTION_ARGUMENT",
45: "CURLE_INTERFACE_FAILED",
47: "CURLE_TOO_MANY_REDIRECTS",
48: "CURLE_UNKNOWN_OPTION",
49: "CURLE_TELNET_OPTION_SYNTAX",
51: "CURLE_PEER_FAILED_VERIFICATION",
52: "CURLE_GOT_NOTHING",
53: "CURLE_SSL_ENGINE_NOTFOUND",
54: "CURLE_SSL_ENGINE_SETFAILED",
55: "CURLE_SEND_ERROR",
56: "CURLE_RECV_ERROR",
58: "CURLE_SSL_CERTPROBLEM",
59: "CURLE_SSL_CIPHER",
60: "CURLE_SSL_CACERT",
61: "CURLE_BAD_CONTENT_ENCODING",
62: "CURLE_LDAP_INVALID_URL",
63: "CURLE_FILESIZE_EXCEEDED",
64: "CURLE_USE_SSL_FAILED",
65: "CURLE_SEND_FAIL_REWIND",
66: "CURLE_SSL_ENGINE_INITFAILED",
67: "CURLE_LOGIN_DENIED",
68: "CURLE_TFTP_NOTFOUND",
69: "CURLE_TFTP_PERM",
70: "CURLE_REMOTE_DISK_FULL",
71: "CURLE_TFTP_ILLEGAL",
72: "CURLE_TFTP_UNKNOWNID",
73: "CURLE_REMOTE_FILE_EXISTS",
74: "CURLE_TFTP_NOSUCHUSER",
75: "CURLE_CONV_FAILED",
76: "CURLE_CONV_REQD",
77: "CURLE_SSL_CACERT_BADFILE",
78: "CURLE_REMOTE_FILE_NOT_FOUND",
79: "CURLE_SSH",
80: "CURLE_SSL_SHUTDOWN_FAILED",
81: "CURLE_AGAIN",
82: "CURLE_SSL_CRL_BADFILE",
83: "CURLE_SSL_ISSUER_ERROR",
84: "CURLE_FTP_PRET_FAILED",
85: "CURLE_RTSP_CSEQ_ERROR",
86: "CURLE_RTSP_SESSION_ERROR",
87: "CURLE_FTP_BAD_FILE_LIST",
88: "CURLE_CHUNK_FAILED",
89: "CURLE_NO_CONNECTION_AVAILABLE",
90: "CURLE_SSL_PINNEDPUBKEYNOTMATCH",
91: "CURLE_SSL_INVALIDCERTSTATUS",
92: "CURLE_HTTP2_STREAM",
93: "CURLE_RECURSIVE_API_CALL",
94: "CURLE_AUTH_ERROR",
95: "CURLE_HTTP3",
96: "CURLE_QUIC_CONNECT_ERROR",
}
170 changes: 170 additions & 0 deletions internal/curl/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright 2026 Google LLC
//
// 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
//
// https://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 curl

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)

// Runner handles the execution of a specific curl binary.
// It manages environment setup (e.g., LD_LIBRARY_PATH) required for custom builds.
type Runner struct {
curlPath string
libPath string
}

// Args defines the execution parameters for a single curl run.
type Args struct {
// Proxy specifies the proxy URL to use.
// Corresponds to the "--proxy" flag.
Proxy string

// ProxyHeaders specifies custom headers to send to the proxy.
// Each string should be in "Key: Value" format.
// Corresponds to the "--proxy-header" flag.
ProxyHeaders []string

// ECH specifies the Encrypted ClientHello mode.
// Corresponds to the "--ech" flag.
// Use ECHGrease, ECHTrue, ECHFalse constants.
// If empty (ECHNone), the flag is omitted.
ECH ECHMode

// Verbose enables verbose output.
// If true, adds "-v".
// If false, adds "-s" (silent mode) by default to keep output clean.
Verbose bool

// Timeout sets the maximum time allowed for the transfer.
// Corresponds to the "--max-time" flag.
// If 0, no timeout is set.
Timeout time.Duration

// MeasureStats enables capturing performance metrics using curl's -w flag.
// If true, Stats will be populated in the Result.
MeasureStats bool
}

// ECHMode defines the available Encrypted ClientHello modes for curl.
type ECHMode string

const (
// ECHGrease enables ECH GREASE mode ("--ech grease").
ECHGrease ECHMode = "grease"
// ECHTrue enables ECH ("--ech true").
ECHTrue ECHMode = "true"
// ECHFalse disables ECH ("--ech false").
ECHFalse ECHMode = "false"
// ECHNone indicates that the --ech flag should not be sent.
ECHNone ECHMode = ""
)

// Result represents the raw outcome of a curl execution.
type Result struct {
// ExitCode is the exit status of the curl process.
// 0 indicates success. See ExitCodeName for error name mapping.
ExitCode int

// Stdout contains the standard output of the curl command.
Stdout string

// Stderr contains the standard error of the curl command.
// In verbose mode, this contains debug information and headers.
Stderr string

// Stats contains performance metrics if MeasureStats was enabled.
Stats Stats
}

// NewRunner creates a new Runner for the specified curl binary.
// It automatically detects the associated library path (bin/curl -> lib/)
// to ensure shared libraries are found.
func NewRunner(curlPath string) *Runner {
r := &Runner{curlPath: curlPath}

binDir := filepath.Dir(curlPath)
libDir := filepath.Join(filepath.Dir(binDir), "lib")
if libStat, err := os.Stat(libDir); err == nil && libStat.IsDir() {
r.libPath = libDir
}

return r
}

// Run executes curl with the provided arguments and returns the result.
func (r *Runner) Run(url string, args Args) (*Result, error) {
var cmdArgs []string

if args.Verbose {
cmdArgs = append(cmdArgs, "-v")
} else {
cmdArgs = append(cmdArgs, "-s")
}

if args.Timeout > 0 {
cmdArgs = append(cmdArgs, "--max-time", strconv.FormatFloat(args.Timeout.Seconds(), 'f', -1, 64))
}

if args.Proxy != "" {
cmdArgs = append(cmdArgs, "--proxy", args.Proxy)
}

for _, h := range args.ProxyHeaders {
cmdArgs = append(cmdArgs, "--proxy-header", h)
}

if args.ECH != ECHNone {
cmdArgs = append(cmdArgs, "--ech", string(args.ECH))
}

if args.MeasureStats {
cmdArgs = append(cmdArgs, "-w", statsFormat)
}

cmdArgs = append(cmdArgs, url)
cmd := exec.Command(r.curlPath, cmdArgs...)
if r.libPath != "" {
cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+r.libPath)
}

var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr

result := &Result{}
err := cmd.Run()
result.Stdout = stdout.String()
result.Stderr = stderr.String()

if args.MeasureStats {
result.Stats = parseStats(result.Stdout)
}

if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
return result, fmt.Errorf("failed to execute curl: %w", err)
}
}

return result, nil
}
101 changes: 101 additions & 0 deletions internal/curl/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright 2026 Google LLC
//
// 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
//
// https://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 curl

import (
"strconv"
"strings"
"time"
)

// Stats captures timing and status metrics from a curl execution.
type Stats struct {
// DNSLookupTimestamp is the cumulative time from the start until the name
// lookup is completed (time_namelookup).
DNSLookupTimestamp time.Duration

// TCPConnectTimestamp is the cumulative time from the start until the TCP
// connection is completed (time_connect).
TCPConnectTimestamp time.Duration

// TLSConnectTimestamp is the cumulative time from the start until the
// SSL/TLS handshake is completed (time_appconnect).
TLSConnectTimestamp time.Duration

// ServerResponseTimestamp is the cumulative time from the start until the
// first byte is received (time_starttransfer).
ServerResponseTimestamp time.Duration

// TotalTimeTimestamp is the total time from the start until the operation is
// fully completed (time_total).
TotalTimeTimestamp time.Duration

// HTTPStatus is the HTTP response code (http_code).
HTTPStatus int
}

const (
// statsPrefix is the delimiter used to identify the statistics block in the output.
statsPrefix = "\n|||CURL_STATS|||\t"

// statsFormat is the format string passed to curl's -w flag.
statsFormat = statsPrefix +
"dnslookup:%{time_namelookup}," +
"tcpconnect:%{time_connect}," +
"tlsconnect:%{time_appconnect}," +
"servertime:%{time_starttransfer}," +
"total:%{time_total}," +
"httpstatus:%{http_code}"
)

// parseStats extracts Stats from the curl output by looking for the statsPrefix.
func parseStats(stdout string) Stats {
var s Stats
idx := strings.LastIndex(stdout, statsPrefix)
if idx == -1 {
return s
}

raw := strings.TrimSpace(stdout[idx+len(statsPrefix):])
for part := range strings.SplitSeq(raw, ",") {
kv := strings.Split(part, ":")
if len(kv) != 2 {
continue
}

key, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
switch key {
case "dnslookup":
s.DNSLookupTimestamp = parseDuration(val)
case "tcpconnect":
s.TCPConnectTimestamp = parseDuration(val)
case "tlsconnect":
s.TLSConnectTimestamp = parseDuration(val)
case "servertime":
s.ServerResponseTimestamp = parseDuration(val)
case "total":
s.TotalTimeTimestamp = parseDuration(val)
case "httpstatus":
s.HTTPStatus, _ = strconv.Atoi(val)
}
}
return s
}

// parseDuration converts a seconds-based float string to time.Duration.
func parseDuration(s string) time.Duration {
f, _ := strconv.ParseFloat(s, 64)
return time.Duration(f * float64(time.Second))
}
Loading