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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

Requests: pin Go 1.27 ML-KEM hybrid CurvePreferences (including P-521 fallback) and print the negotiated key exchange.

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

### 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
15 changes: 8 additions & 7 deletions internal/cmd/jwtinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/MicahParks/keyfunc/v3"
"github.com/spf13/cobra"
"github.com/xenos76/https-wrench/internal/errdisp"
"github.com/xenos76/https-wrench/internal/jwtinfo"
"github.com/xenos76/https-wrench/internal/style"
)
Expand Down Expand Up @@ -123,7 +124,7 @@ Examples:
if err != nil {
cmd.Printf(
"error while reading token value from file: %s",
err,
errdisp.FormatCause(err),
)

return
Expand Down Expand Up @@ -153,7 +154,7 @@ Examples:
}

if err != nil {
cmd.Printf("error processing %s: %s\n", step.kind, err)
cmd.Printf("error processing %s: %s\n", step.kind, errdisp.FormatCause(err))
return
}
}
Expand All @@ -166,29 +167,29 @@ Examples:
io.ReadAll,
)
if err != nil {
cmd.Printf("error while requesting token data: %s\n", err)
cmd.Printf("error while requesting token data: %s\n", errdisp.FormatCause(err))
return
}
}

if tokenData != nil && tokenData.AccessTokenRaw != "" {
err = tokenData.DecodeBase64()
if err != nil {
cmd.Printf("DecodeBase64 error: %s\n", err)
cmd.Printf("DecodeBase64 error: %s\n", errdisp.FormatCause(err))
return
}

if jwksURL != "" {
err = tokenData.ParseWithJWKS(cmd.Context(), jwksURL, keyfuncDefOverride)
if err != nil {
cmd.Printf("error while parsing token data: %s\n", err)
cmd.Printf("error while parsing token data: %s\n", errdisp.FormatCause(err))
return
}
}

err = jwtinfo.PrintTokenInfo(tokenData, cmd.OutOrStdout())
if err != nil {
cmd.Printf("error while printing token data: %s\n", err)
cmd.Printf("error while printing token data: %s\n", errdisp.FormatCause(err))
return
}

Expand Down Expand Up @@ -222,7 +223,7 @@ Examples:
cmd.OutOrStdout(),
)
if err != nil {
cmd.Printf("Refresh loop exited with error: %s\n", err)
cmd.Printf("Refresh loop exited with error: %s\n", errdisp.FormatCause(err))
} else {
cmd.Printf("Refresh loop stopped gracefully.\n")
}
Expand Down
60 changes: 54 additions & 6 deletions internal/errdisp/errdisp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ 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.
// It holds no sentinels; domain identity stays in packages such as certinfo
// and jwtinfo.
package errdisp

import (
"errors"
"strings"

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

// Cause returns the deepest single-cause unwrap of err.
Expand All @@ -29,8 +31,8 @@ func Cause(err error) error {
}

// FormatCause returns a short message for callers that already print an
// operation prefix. Prefer certinfo domain leaves via Is/As; otherwise the
// deepest cause.
// operation prefix. Prefer domain leaves via Is/As; otherwise the deepest
// cause.
func FormatCause(err error) string {
if err == nil {
return ""
Expand All @@ -44,8 +46,8 @@ func FormatCause(err error) string {
}

// Format returns a user-facing message when the caller has no operation prefix.
// Prefer certinfo domain leaves via Is/As; otherwise top wrap label + deepest
// cause, skipping intermediate layers.
// Prefer domain leaves via Is/As; otherwise top wrap label + deepest cause,
// skipping intermediate layers.
func Format(err error) string {
if err == nil {
return ""
Expand All @@ -69,7 +71,7 @@ func Format(err error) string {
return label + ": " + cause.Error()
}

// domainLeaf returns a certinfo leaf message when err matches a known domain failure.
// domainLeaf returns a domain leaf message when err matches a known failure.
func domainLeaf(err error) (string, bool) {
if empty, ok := errors.AsType[*certinfo.EmptyArgError](err); ok {
return empty.Error(), true
Expand All @@ -83,6 +85,38 @@ func domainLeaf(err error) (string, bool) {
return keyType.Error(), true
}

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

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

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

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

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

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

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

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

for _, s := range []error{
certinfo.ErrNilReader,
certinfo.ErrPEMDecode,
Expand All @@ -93,6 +127,20 @@ func domainLeaf(err error) (string, bool) {
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 errors.Is(err, s) {
return s.Error(), true
Expand Down
31 changes: 31 additions & 0 deletions internal/errdisp/errdisp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

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

func TestCause(t *testing.T) {
Expand All @@ -25,6 +26,7 @@ func TestCause(t *testing.T) {
require.Equal(t, leaf, Cause(wrapped))
}

// TestFormatCause checks FormatCause prefers domain leaves over wrap text.
func TestFormatCause(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -57,8 +59,30 @@ func TestFormatCause(t *testing.T) {
err := fmt.Errorf("wrap: %w", &certinfo.EmptyArgError{Name: "caBundlePath"})
require.Equal(t, "empty string provided as caBundlePath", FormatCause(err))
})

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

err := fmt.Errorf("wrap: %w", &jwtinfo.EmptyArgError{Name: "request URL"})
require.Equal(t, "empty string provided as request URL", FormatCause(err))
})

t.Run("jwtinfo token status", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("request: %w", &jwtinfo.TokenStatusError{Code: 401})
require.Equal(t, "token request returned the following status code: 401", FormatCause(err))
})

t.Run("jwtinfo claim missing", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("claims: %w", &jwtinfo.ClaimError{Claim: "exp", Kind: jwtinfo.ClaimMissing})
require.Equal(t, "exp claim missing", FormatCause(err))
})
}

// TestFormat checks Format surfaces domain leaves or top label plus cause.
func TestFormat(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -87,4 +111,11 @@ func TestFormat(t *testing.T) {

require.Equal(t, "boom", Format(errors.New("boom")))
})

t.Run("jwtinfo sentinel leaf only", func(t *testing.T) {
t.Parallel()

err := fmt.Errorf("failed to request refreshed token: %w", jwtinfo.ErrEmptyRequestValues)
require.Equal(t, jwtinfo.ErrEmptyRequestValues.Error(), Format(err))
})
}
Loading
Loading