aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-10-21 05:17:25 +0900
committerOphestra <cat@gensokyo.uk>2025-10-21 05:17:25 +0900
commitd42067df7c48f905edb6edb20ce11d2247938354 (patch)
treec7108b5f74ea38b42e2c1cf7299483de573a6b14 /cmd
parentb9459a80c7d178d6c5b199dcab77749cb3686741 (diff)
cmd/hakurei/json: friendly error messages
This change handles errors returned by encoding/json and prints significantly cleaner messages. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/hakurei/command.go17
-rw-r--r--cmd/hakurei/json.go60
-rw-r--r--cmd/hakurei/json_test.go107
-rw-r--r--cmd/hakurei/parse.go6
-rw-r--r--cmd/hakurei/print.go33
5 files changed, 196 insertions, 27 deletions
diff --git a/cmd/hakurei/command.go b/cmd/hakurei/command.go
index 72152537..2a387607 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 00000000..30eb4334
--- /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 00000000..5e5daf0b
--- /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 75d720d6..f93ac6eb 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 b09ba4a6..040a3b04 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)