From 7526401656ec8118b59130968990204dae9e5c87 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 20:31:45 +0000 Subject: [PATCH] cmd/headscale: fix JSON output of value-typed API objects Generated API types have a pointer-receiver MarshalJSON that omits unset optional fields. The CLI marshalled them by value, so stdlib reflection called Opt*.MarshalJSON directly and failed with "unexpected end of JSON input", breaking every -o json/-o yaml command. Make values addressable first so the generated marshaler runs. --- cmd/headscale/cli/utils.go | 29 ++++++++++++++++++++ cmd/headscale/cli/utils_test.go | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 cmd/headscale/cli/utils_test.go diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 8282b0b4..1e49bb4d 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "reflect" "slices" "github.com/juanfont/headscale/hscontrol" @@ -58,9 +59,37 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) { return app, nil } +// addressableForJSON makes value-typed generated API structs (and slice +// elements) addressable so their pointer-receiver MarshalJSON is used. That +// method omits unset optional fields; stdlib reflection over a value instead +// calls Opt*.MarshalJSON directly, which returns empty for an unset field and +// fails with "unexpected end of JSON input". +func addressableForJSON(v any) any { + rv := reflect.ValueOf(v) + + switch rv.Kind() { //nolint:exhaustive // default handles all other kinds + case reflect.Slice, reflect.Array: + out := make([]any, rv.Len()) + for i := range out { + out[i] = addressableForJSON(rv.Index(i).Interface()) + } + + return out + case reflect.Struct: + p := reflect.New(rv.Type()) + p.Elem().Set(rv) + + return p.Interface() + default: + return v + } +} + // formatOutput serialises result into the requested format. For the // default (empty) format the human-readable override string is returned. func formatOutput(result any, override string, outputFormat string) (string, error) { + result = addressableForJSON(result) + switch outputFormat { case outputFormatJSON: b, err := json.MarshalIndent(result, "", "\t") diff --git a/cmd/headscale/cli/utils_test.go b/cmd/headscale/cli/utils_test.go new file mode 100644 index 00000000..d2827f34 --- /dev/null +++ b/cmd/headscale/cli/utils_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + apiv1 "github.com/juanfont/headscale/gen/api/v1" +) + +// TestFormatOutputValueWithUnsetOptional guards against a regression where the +// generated API types, passed by value with unset optional fields, fail to +// marshal because stdlib reflection calls Opt*.MarshalJSON directly. +func TestFormatOutputValueWithUnsetOptional(t *testing.T) { + // DisplayName, Email, CreatedAt and PictureURL are intentionally unset. + user := apiv1.User{ + ID: apiv1.NewOptUint64(1), + Name: apiv1.NewOptString("alice"), + } + + for _, format := range []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML} { + out, err := formatOutput(user, "", format) + if err != nil { + t.Fatalf("formatOutput(%s) on value type: %v", format, err) + } + + if !strings.Contains(out, "alice") { + t.Errorf("formatOutput(%s) missing name: %q", format, out) + } + } + + // A slice of value types must work too (e.g. "users list"). + listOut, err := formatOutput([]apiv1.User{user}, "", outputFormatJSON) + if err != nil { + t.Fatalf("formatOutput(json) on slice of value type: %v", err) + } + + var decoded []map[string]any + + err = json.Unmarshal([]byte(listOut), &decoded) + if err != nil { + t.Fatalf("list output is not valid JSON: %v\n%s", err, listOut) + } + + if len(decoded) != 1 || decoded[0]["name"] != "alice" { + t.Errorf("unexpected list output: %s", listOut) + } +}