From d42067df7c48f905edb6edb20ce11d2247938354 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 21 Oct 2025 05:17:25 +0900 Subject: [PATCH] cmd/hakurei/json: friendly error messages This change handles errors returned by encoding/json and prints significantly cleaner messages. Signed-off-by: Ophestra --- cmd/hakurei/command.go | 17 ++++--- cmd/hakurei/json.go | 60 ++++++++++++++++++++++ cmd/hakurei/json_test.go | 107 +++++++++++++++++++++++++++++++++++++++ cmd/hakurei/parse.go | 6 +-- cmd/hakurei/print.go | 33 ++++++------ 5 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 cmd/hakurei/json.go create mode 100644 cmd/hakurei/json_test.go diff --git a/cmd/hakurei/command.go b/cmd/hakurei/command.go index 7215253..2a38760 100644 --- a/cmd/hakurei/command.go +++ b/cmd/hakurei/command.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "io" "log" @@ -227,8 +226,11 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr } else { if f, err := os.Open(flagDBusConfigSession); err != nil { log.Fatal(err.Error()) - } else if err = json.NewDecoder(f).Decode(&config.SessionBus); err != nil { - log.Fatalf("cannot load session bus proxy config from %q: %s", flagDBusConfigSession, err) + } else { + decodeJSON(log.Fatal, "load session bus proxy config", f, &config.SessionBus) + if err = f.Close(); err != nil { + log.Fatal(err.Error()) + } } } @@ -236,8 +238,11 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr if flagDBusConfigSystem != "nil" { if f, err := os.Open(flagDBusConfigSystem); err != nil { log.Fatal(err.Error()) - } else if err = json.NewDecoder(f).Decode(&config.SystemBus); err != nil { - log.Fatalf("cannot load system bus proxy config from %q: %s", flagDBusConfigSystem, err) + } else { + decodeJSON(log.Fatal, "load system bus proxy config", f, &config.SystemBus) + if err = f.Close(); err != nil { + log.Fatal(err.Error()) + } } } @@ -323,7 +328,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr c.Command("version", "Display version information", func(args []string) error { fmt.Println(internal.Version()); return errSuccess }) c.Command("license", "Show full license text", func(args []string) error { fmt.Println(license); return errSuccess }) - c.Command("template", "Produce a config template", func(args []string) error { printJSON(os.Stdout, false, hst.Template()); return errSuccess }) + c.Command("template", "Produce a config template", func(args []string) error { encodeJSON(log.Fatal, os.Stdout, false, hst.Template()); return errSuccess }) c.Command("help", "Show this help message", func([]string) error { c.PrintHelp(); return errSuccess }) return c diff --git a/cmd/hakurei/json.go b/cmd/hakurei/json.go new file mode 100644 index 0000000..30eb433 --- /dev/null +++ b/cmd/hakurei/json.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "strconv" +) + +// decodeJSON decodes json from r and stores it in v. A non-nil error results in a call to fatal. +func decodeJSON(fatal func(v ...any), op string, r io.Reader, v any) { + err := json.NewDecoder(r).Decode(v) + if err == nil { + return + } + + var ( + syntaxError *json.SyntaxError + unmarshalTypeError *json.UnmarshalTypeError + + msg string + ) + + switch { + case errors.As(err, &syntaxError) && syntaxError != nil: + msg = syntaxError.Error() + + " at byte " + strconv.FormatInt(syntaxError.Offset, 10) + + case errors.As(err, &unmarshalTypeError) && unmarshalTypeError != nil: + msg = "inappropriate " + unmarshalTypeError.Value + + " at byte " + strconv.FormatInt(unmarshalTypeError.Offset, 10) + + default: + // InvalidUnmarshalError: incorrect usage, does not need to be handled + // io.ErrUnexpectedEOF: no additional error information available + msg = err.Error() + } + + fatal("cannot " + op + ": " + msg) +} + +// encodeJSON encodes v to output. A non-nil error results in a call to fatal. +func encodeJSON(fatal func(v ...any), output io.Writer, short bool, v any) { + encoder := json.NewEncoder(output) + if !short { + encoder.SetIndent("", " ") + } + + if err := encoder.Encode(v); err != nil { + var marshalerError *json.MarshalerError + if errors.As(err, &marshalerError) && marshalerError != nil { + // this likely indicates an implementation error in hst + fatal("cannot encode json for " + marshalerError.Type.String() + ": " + marshalerError.Err.Error()) + return + } + + // UnsupportedTypeError, UnsupportedValueError: incorrect usage, does not need to be handled + fatal("cannot write json: " + err.Error()) + } +} diff --git a/cmd/hakurei/json_test.go b/cmd/hakurei/json_test.go new file mode 100644 index 0000000..5e5daf0 --- /dev/null +++ b/cmd/hakurei/json_test.go @@ -0,0 +1,107 @@ +package main_test + +import ( + "io" + "reflect" + "strings" + "testing" + _ "unsafe" + + "hakurei.app/container/stub" +) + +//go:linkname decodeJSON hakurei.app/cmd/hakurei.decodeJSON +func decodeJSON(fatal func(v ...any), op string, r io.Reader, v any) + +func TestDecodeJSON(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + t reflect.Type + data string + want any + msg string + }{ + {"success", reflect.TypeFor[uintptr](), "3735928559\n", uintptr(0xdeadbeef), ""}, + + {"syntax", reflect.TypeFor[*int](), "\x00", nil, + `cannot load sample: invalid character '\x00' looking for beginning of value at byte 1`}, + {"type", reflect.TypeFor[uintptr](), "-1", nil, + `cannot load sample: inappropriate number -1 at byte 2`}, + {"default", reflect.TypeFor[*int](), "{", nil, + "cannot load sample: unexpected EOF"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + gotP = reflect.New(tc.t) + gotMsg *string + ) + decodeJSON(func(v ...any) { + if gotMsg != nil { + t.Fatal("fatal called twice") + } + msg := v[0].(string) + gotMsg = &msg + }, "load sample", strings.NewReader(tc.data), gotP.Interface()) + if tc.msg != "" { + if gotMsg == nil { + t.Errorf("decodeJSON: success, want fatal %q", tc.msg) + } else if *gotMsg != tc.msg { + t.Errorf("decodeJSON: fatal = %q, want %q", *gotMsg, tc.msg) + } + } else if gotMsg != nil { + t.Errorf("decodeJSON: fatal = %q", *gotMsg) + } else if !reflect.DeepEqual(gotP.Elem().Interface(), tc.want) { + t.Errorf("decodeJSON: %#v, want %#v", gotP.Elem().Interface(), tc.want) + } + }) + } +} + +//go:linkname encodeJSON hakurei.app/cmd/hakurei.encodeJSON +func encodeJSON(fatal func(v ...any), output io.Writer, short bool, v any) + +func TestEncodeJSON(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + v any + want string + }{ + {"marshaler", errorJSONMarshaler{}, + `cannot encode json for main_test.errorJSONMarshaler: unique error 3735928559 injected by the test suite`}, + {"default", func() {}, + `cannot write json: json: unsupported type: func()`}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var called bool + encodeJSON(func(v ...any) { + if called { + t.Fatal("fatal called twice") + } + called = true + + if v[0].(string) != tc.want { + t.Errorf("encodeJSON: fatal = %q, want %q", v[0].(string), tc.want) + } + }, nil, false, tc.v) + + if !called { + t.Errorf("encodeJSON: success, want fatal %q", tc.want) + } + }) + } +} + +// errorJSONMarshaler implements json.Marshaler. +type errorJSONMarshaler struct{} + +func (errorJSONMarshaler) MarshalJSON() ([]byte, error) { return nil, stub.UniqueError(0xdeadbeef) } diff --git a/cmd/hakurei/parse.go b/cmd/hakurei/parse.go index 75d720d..f93ac6e 100644 --- a/cmd/hakurei/parse.go +++ b/cmd/hakurei/parse.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "errors" "io" "log" @@ -42,10 +41,7 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) { r = os.Stdin } - if err := json.NewDecoder(r).Decode(&config); err != nil { - log.Fatalf("cannot load configuration: %v", err) - } - + decodeJSON(log.Fatal, "load configuration", r, &config) return } diff --git a/cmd/hakurei/print.go b/cmd/hakurei/print.go index b09ba4a..040a3b0 100644 --- a/cmd/hakurei/print.go +++ b/cmd/hakurei/print.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "io" "log" @@ -18,6 +17,7 @@ import ( "hakurei.app/message" ) +// printShowSystem populates and writes a representation of [hst.Info] to output. func printShowSystem(output io.Writer, short, flagJSON bool) { t := newPrinter(output) defer t.MustFlush() @@ -26,7 +26,7 @@ func printShowSystem(output io.Writer, short, flagJSON bool) { app.CopyPaths().Copy(&info.Paths, info.User) if flagJSON { - printJSON(output, short, info) + encodeJSON(log.Fatal, output, short, info) return } @@ -38,6 +38,7 @@ func printShowSystem(output io.Writer, short, flagJSON bool) { t.Printf("RunDirPath:\t%s\n", info.RunDirPath) } +// printShowInstance writes a representation of [state.State] or [hst.Config] to output. func printShowInstance( output io.Writer, now time.Time, instance *state.State, config *hst.Config, @@ -46,9 +47,9 @@ func printShowInstance( if flagJSON { if instance != nil { - printJSON(output, short, instance) + encodeJSON(log.Fatal, output, short, instance) } else { - printJSON(output, short, config) + encodeJSON(log.Fatal, output, short, config) } return } @@ -170,6 +171,7 @@ func printShowInstance( return } +// printPs writes a representation of active instances to output. func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) { var entries state.Entries if e, err := state.Join(s); err != nil { @@ -186,7 +188,7 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo for id, instance := range entries { es[id.String()] = instance } - printJSON(output, short, es) + encodeJSON(log.Fatal, output, short, es) return } @@ -215,7 +217,7 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo for i, e := range exp { v[i] = e.s } - printJSON(output, short, v) + encodeJSON(log.Fatal, output, short, v) } else { for _, e := range exp { mustPrintln(output, e.s[:8]) @@ -249,40 +251,39 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo } } +// expandedStateEntry stores [state.State] alongside a string representation of its [state.ID]. type expandedStateEntry struct { s string *state.State } -func printJSON(output io.Writer, short bool, v any) { - encoder := json.NewEncoder(output) - if !short { - encoder.SetIndent("", " ") - } - if err := encoder.Encode(v); err != nil { - log.Fatalf("cannot serialise: %v", err) - } -} - +// newPrinter returns a configured, wrapped [tabwriter.Writer]. func newPrinter(output io.Writer) *tp { return &tp{tabwriter.NewWriter(output, 0, 1, 4, ' ', 0)} } +// tp wraps [tabwriter.Writer] to provide additional formatting methods. type tp struct{ *tabwriter.Writer } +// Printf calls [fmt.Fprintf] on the underlying [tabwriter.Writer]. func (p *tp) Printf(format string, a ...any) { if _, err := fmt.Fprintf(p, format, a...); err != nil { log.Fatalf("cannot write to tabwriter: %v", err) } } + +// Println calls [fmt.Fprintln] on the underlying [tabwriter.Writer]. func (p *tp) Println(a ...any) { if _, err := fmt.Fprintln(p, a...); err != nil { log.Fatalf("cannot write to tabwriter: %v", err) } } + +// MustFlush calls the Flush method of [tabwriter.Writer] and calls [log.Fatalf] on a non-nil error. func (p *tp) MustFlush() { if err := p.Writer.Flush(); err != nil { log.Fatalf("cannot flush tabwriter: %v", err) } } + func mustPrint(output io.Writer, a ...any) { if _, err := fmt.Fprint(output, a...); err != nil { log.Fatalf("cannot print: %v", err)