Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@

Jwtinfo: add sentinel and typed errors for errors.Is/As, and route CLI/MCP display through errdisp domain leaves.

Jwks: add sentinel errors for PEM decode, unsupported key, and non-public key; route CLI display through errdisp.

Requests: add sentinel and typed errors for client/validation leaves (nil client, empty args, serverName URL, wrong transport, timeout, proxyproto, URI, transport URL).

Mcp: add sentinel and typed errors for tool-boundary input rules (config/token sources, required fields, TLS info, encrypted key env, validation joins).

Certinfo: add InvalidTLSEndpointError for host:port parse failures.

Jwtinfo: add typed errors for base64 JWT parts, JWT parse sources, and invalid request-values JSON.

### Fix

Devenv: prefer httpbin on 127.0.0.1:8081 and proxy nginx upstreams through the allocated httpbin port so `devenv test` keeps working when the preferred port is already taken; fail fast in request integration tests with `set -e`, enable `pipefail` on success-case request leaf pipelines, and assert request exit status separately from expected error text.
Expand Down
2 changes: 1 addition & 1 deletion internal/certinfo/certinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (c *Config) SetTLSEndpoint(ctx context.Context, hostport string) error {
if hostport != emptyString {
eHost, ePort, err := net.SplitHostPort(hostport)
if err != nil {
return fmt.Errorf("invalid TLS endpoint %q: %w", hostport, err)
return fmt.Errorf("%w: %w", &InvalidTLSEndpointError{Endpoint: hostport}, err)
}

c.TLSEndpoint = hostport
Expand Down
17 changes: 17 additions & 0 deletions internal/certinfo/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var (
ErrEmptyArg = errors.New("empty string provided as argument")
ErrNoCertsInFile = errors.New("no valid certificates found in file")
ErrUnrecognizedKeyType = errors.New("unrecognized private key type")
ErrInvalidTLSEndpoint = errors.New("invalid TLS endpoint")
)

// EmptyArgError is returned when a required string argument is empty.
Expand Down Expand Up @@ -66,3 +67,19 @@ func (e *UnrecognizedKeyTypeError) Error() string {
func (*UnrecognizedKeyTypeError) Is(target error) bool {
return target == ErrUnrecognizedKeyType
}

// InvalidTLSEndpointError is returned when host:port cannot be split.
// errors.Is(err, ErrInvalidTLSEndpoint) is true.
type InvalidTLSEndpointError struct {
Endpoint string
}

// Error returns a message including the invalid endpoint.
func (e *InvalidTLSEndpointError) Error() string {
return fmt.Sprintf("invalid TLS endpoint %q", e.Endpoint)
}

// Is reports whether target is ErrInvalidTLSEndpoint.
func (*InvalidTLSEndpointError) Is(target error) bool {
return target == ErrInvalidTLSEndpoint
}
17 changes: 17 additions & 0 deletions internal/certinfo/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
)

// TestCertinfo_errorSentinels_Is checks errors.Is against certinfo sentinels.
func TestCertinfo_errorSentinels_Is(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -66,6 +67,16 @@ func TestCertinfo_errorSentinels_Is(t *testing.T) {
err: ErrUnsupportedPublicKey,
target: ErrUnsupportedPublicKey,
},
{
name: "InvalidTLSEndpointError",
err: &InvalidTLSEndpointError{Endpoint: "bad"},
target: ErrInvalidTLSEndpoint,
},
{
name: "InvalidTLSEndpointError wrapped",
err: fmt.Errorf("set: %w", &InvalidTLSEndpointError{Endpoint: "x"}),
target: ErrInvalidTLSEndpoint,
},
}

for _, tt := range tests {
Expand All @@ -76,6 +87,7 @@ func TestCertinfo_errorSentinels_Is(t *testing.T) {
}
}

// TestCertinfo_errorTypes_AsType checks errors.AsType for typed certinfo errors.
func TestCertinfo_errorTypes_AsType(t *testing.T) {
t.Parallel()

Expand All @@ -93,4 +105,9 @@ func TestCertinfo_errorTypes_AsType(t *testing.T) {
gotKeyType, ok := errors.AsType[*UnrecognizedKeyTypeError](keyType)
require.True(t, ok)
require.Equal(t, "CERTIFICATE", gotKeyType.Type)

tlsEp := fmt.Errorf("wrap: %w", &InvalidTLSEndpointError{Endpoint: "no-port"})
gotTLS, ok := errors.AsType[*InvalidTLSEndpointError](tlsEp)
require.True(t, ok)
require.Equal(t, "no-port", gotTLS.Endpoint)
}
3 changes: 2 additions & 1 deletion internal/cmd/jwks.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"

"github.com/spf13/cobra"
"github.com/xenos76/https-wrench/internal/errdisp"
"github.com/xenos76/https-wrench/internal/jwks"
"github.com/xenos76/https-wrench/internal/style"
)
Expand Down Expand Up @@ -31,7 +32,7 @@ Examples:
Run: func(cmd *cobra.Command, _ []string) {
jwksJSON, err := jwks.GenerateJWKS(cmd.Context(), jwksPublicKeyFile, jwksKID)
if err != nil {
cmd.PrintErrf("Error generating JWKS: %s\n", err)
cmd.PrintErrf("Error generating JWKS: %s\n", errdisp.FormatCause(err))

return
}
Expand Down
148 changes: 121 additions & 27 deletions internal/errdisp/errdisp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ Copyright © 2025 Zeno Belli xeno@os76.xyz
*/

// Package errdisp formats errors for CLI and MCP user boundaries.
// It holds no sentinels; domain identity stays in packages such as certinfo
// and jwtinfo.
// It holds no sentinels; domain identity stays in packages such as certinfo,
// jwtinfo, jwks, and requests. MCP tool-boundary errors stay in package mcp
// (errdisp cannot import mcp without a cycle); bare MCP leaves still format
// via Error() when Format finds no registered domain leaf.
package errdisp

import (
"errors"
"strings"

"github.com/xenos76/https-wrench/internal/certinfo"
"github.com/xenos76/https-wrench/internal/jwks"
"github.com/xenos76/https-wrench/internal/jwtinfo"
"github.com/xenos76/https-wrench/internal/requests"
)

// Cause returns the deepest single-cause unwrap of err.
Expand Down Expand Up @@ -73,6 +77,23 @@ func Format(err error) string {

// domainLeaf returns a domain leaf message when err matches a known failure.
func domainLeaf(err error) (string, bool) {
if msg, ok := certinfoTypedLeaf(err); ok {
return msg, true
}

if msg, ok := jwtinfoTypedLeaf(err); ok {
return msg, true
}

if msg, ok := requestsTypedLeaf(err); ok {
return msg, true
}

return domainSentinelLeaf(err)
}

// certinfoTypedLeaf returns a certinfo typed-error leaf message when matched.
func certinfoTypedLeaf(err error) (string, bool) {
if empty, ok := errors.AsType[*certinfo.EmptyArgError](err); ok {
return empty.Error(), true
}
Expand All @@ -85,6 +106,15 @@ func domainLeaf(err error) (string, bool) {
return keyType.Error(), true
}

if tlsEp, ok := errors.AsType[*certinfo.InvalidTLSEndpointError](err); ok {
return tlsEp.Error(), true
}

return "", false
}

// jwtinfoTypedLeaf returns a jwtinfo typed-error leaf message when matched.
func jwtinfoTypedLeaf(err error) (string, bool) {
if empty, ok := errors.AsType[*jwtinfo.EmptyArgError](err); ok {
return empty.Error(), true
}
Expand Down Expand Up @@ -117,31 +147,49 @@ func domainLeaf(err error) (string, bool) {
return thr.Error(), true
}

for _, s := range []error{
certinfo.ErrNilReader,
certinfo.ErrPEMDecode,
certinfo.ErrCertPoolFromFile,
certinfo.ErrNoCertsInConfig,
certinfo.ErrUnsupportedKey,
certinfo.ErrUnsupportedPublicKey,
certinfo.ErrEmptyArg,
certinfo.ErrNoCertsInFile,
certinfo.ErrUnrecognizedKeyType,
jwtinfo.ErrNilBodyReader,
jwtinfo.ErrEmptyRequestValues,
jwtinfo.ErrEmptyArg,
jwtinfo.ErrInvalidJWTFormat,
jwtinfo.ErrInvalidHeaderJSON,
jwtinfo.ErrInvalidClaimsJSON,
jwtinfo.ErrEmptyClaims,
jwtinfo.ErrClaimMissing,
jwtinfo.ErrClaimNotNumeric,
jwtinfo.ErrInvalidKV,
jwtinfo.ErrEmptyParamName,
jwtinfo.ErrInvalidRenewThreshold,
jwtinfo.ErrTokenLifetimeInvalid,
jwtinfo.ErrTokenRequestStatus,
} {
if b64, ok := errors.AsType[*jwtinfo.InvalidBase64PartError](err); ok {
return b64.Error(), true
}

if parse, ok := errors.AsType[*jwtinfo.JWTParseError](err); ok {
return parse.Error(), true
}

return "", false
}

// requestsTypedLeaf returns a requests typed-error leaf message when matched.
func requestsTypedLeaf(err error) (string, bool) {
if empty, ok := errors.AsType[*requests.EmptyArgError](err); ok {
return empty.Error(), true
}

if snURL, ok := errors.AsType[*requests.ServerNameURLError](err); ok {
return snURL.Error(), true
}

if wt, ok := errors.AsType[*requests.WrongTransportError](err); ok {
return wt.Error(), true
}

if to, ok := errors.AsType[*requests.InvalidTimeoutError](err); ok {
return to.Error(), true
}

if uri, ok := errors.AsType[*requests.InvalidURIError](err); ok {
return uri.Error(), true
}

if turl, ok := errors.AsType[*requests.InvalidTransportURLError](err); ok {
return turl.Error(), true
}

return "", false
}

// domainSentinelLeaf returns a package-sentinel leaf message when errors.Is matches.
func domainSentinelLeaf(err error) (string, bool) {
for _, s := range domainSentinels {
if errors.Is(err, s) {
return s.Error(), true
}
Expand All @@ -150,6 +198,52 @@ func domainLeaf(err error) (string, bool) {
return "", false
}

// domainSentinels lists stable package sentinels matched with errors.Is.
var domainSentinels = []error{
certinfo.ErrNilReader,
certinfo.ErrPEMDecode,
certinfo.ErrCertPoolFromFile,
certinfo.ErrNoCertsInConfig,
certinfo.ErrUnsupportedKey,
certinfo.ErrUnsupportedPublicKey,
certinfo.ErrEmptyArg,
certinfo.ErrNoCertsInFile,
certinfo.ErrUnrecognizedKeyType,
certinfo.ErrInvalidTLSEndpoint,
jwtinfo.ErrNilBodyReader,
jwtinfo.ErrEmptyRequestValues,
jwtinfo.ErrEmptyArg,
jwtinfo.ErrInvalidJWTFormat,
jwtinfo.ErrInvalidHeaderJSON,
jwtinfo.ErrInvalidClaimsJSON,
jwtinfo.ErrEmptyClaims,
jwtinfo.ErrClaimMissing,
jwtinfo.ErrClaimNotNumeric,
jwtinfo.ErrInvalidKV,
jwtinfo.ErrEmptyParamName,
jwtinfo.ErrInvalidRenewThreshold,
jwtinfo.ErrTokenLifetimeInvalid,
jwtinfo.ErrTokenRequestStatus,
jwtinfo.ErrInvalidBase64Header,
jwtinfo.ErrInvalidBase64Claims,
jwtinfo.ErrJWTParse,
jwtinfo.ErrInvalidRequestJSON,
jwks.ErrPEMDecode,
jwks.ErrUnsupportedPublicKey,
jwks.ErrNotPublicKey,
requests.ErrMethodNotFound,
requests.ErrNilClient,
requests.ErrEmptyArg,
requests.ErrServerNameIsURL,
requests.ErrWrongTransport,
requests.ErrInvalidTimeout,
requests.ErrProxyProtoNeedsOverride,
requests.ErrProxyProtoDisabled,
requests.ErrTransportOverrideRequired,
requests.ErrInvalidURI,
requests.ErrInvalidTransportURL,
}

// topLabel returns the outermost wrap text without the unwrapped suffix.
func topLabel(err error) string {
u := errors.Unwrap(err)
Expand Down
24 changes: 24 additions & 0 deletions internal/errdisp/errdisp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import (

"github.com/stretchr/testify/require"
"github.com/xenos76/https-wrench/internal/certinfo"
"github.com/xenos76/https-wrench/internal/jwks"
"github.com/xenos76/https-wrench/internal/jwtinfo"
"github.com/xenos76/https-wrench/internal/requests"
)

// TestCause checks Cause unwraps to the deepest single-cause leaf.
func TestCause(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -80,6 +83,27 @@ func TestFormatCause(t *testing.T) {
err := fmt.Errorf("claims: %w", &jwtinfo.ClaimError{Claim: "exp", Kind: jwtinfo.ClaimMissing})
require.Equal(t, "exp claim missing", FormatCause(err))
})

t.Run("jwks PEM sentinel", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("generate: %w", jwks.ErrPEMDecode)
require.Equal(t, jwks.ErrPEMDecode.Error(), FormatCause(err))
})

t.Run("requests empty arg", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("SetServerName error: %w", &requests.EmptyArgError{Name: "serverName"})
require.Equal(t, "empty string provided as serverName", FormatCause(err))
})

t.Run("certinfo invalid TLS endpoint", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("set: %w", &certinfo.InvalidTLSEndpointError{Endpoint: "bad"})
require.Equal(t, `invalid TLS endpoint "bad"`, FormatCause(err))
})
}

// TestFormat checks Format surfaces domain leaves or top label plus cause.
Expand Down
16 changes: 16 additions & 0 deletions internal/jwks/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package jwks

import (
"errors"
)

// Package-level sentinels for stable jwks failure conditions.
// Match them with errors.Is after wrapping; Error() strings stay human-facing.
var (
ErrPEMDecode = errors.New("failed to decode PEM block from public key file")
ErrUnsupportedPublicKey = errors.New("unsupported or invalid public key format")
ErrNotPublicKey = errors.New(
"the provided file does not contain a supported public key " +
"(it might be a private key or an unsupported format)",
)
)
Loading
Loading