Browse Source

util/syspolicy/setting: use a custom marshaler for time.Duration

jsonv2 now returns an error when you marshal or unmarshal a time.Duration
without an explicit format flag. This is an intentional, temporary choice until
the default [time.Duration] representation is decided (see golang/go#71631).

setting.Snapshot can hold time.Duration values inside a map[string]any,
so the jsonv2 update breaks marshaling. In this PR, we start using
a custom marshaler until that decision is made or golang/go#71664
lets us specify the format explicitly.

This fixes `tailscale syspolicy list` failing when KeyExpirationNotice
or any other time.Duration policy setting is configured.

Fixes #16683

Signed-off-by: Nick Khyl <[email protected]>
Nick Khyl 7 months ago
parent
commit
4df02bbb48
2 changed files with 32 additions and 1 deletions
  1. 20 1
      util/syspolicy/setting/snapshot.go
  2. 12 0
      util/syspolicy/setting/snapshot_test.go

+ 20 - 1
util/syspolicy/setting/snapshot.go

@@ -9,6 +9,7 @@ import (
 	"maps"
 	"maps"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
+	"time"
 
 
 	jsonv2 "github.com/go-json-experiment/json"
 	jsonv2 "github.com/go-json-experiment/json"
 	"github.com/go-json-experiment/json/jsontext"
 	"github.com/go-json-experiment/json/jsontext"
@@ -152,6 +153,24 @@ var (
 	_ jsonv2.UnmarshalerFrom = (*Snapshot)(nil)
 	_ jsonv2.UnmarshalerFrom = (*Snapshot)(nil)
 )
 )
 
 
+// As of 2025-07-28, jsonv2 no longer has a default representation for [time.Duration],
+// so we need to provide a custom marshaler.
+//
+// This is temporary until the decision on the default representation is made
+// (see https://github.com/golang/go/issues/71631#issuecomment-2981670799).
+//
+// In the future, we might either use the default representation (if compatible with
+// [time.Duration.String]) or specify something like json.WithFormat[time.Duration]("units")
+// when golang/go#71664 is implemented.
+//
+// TODO(nickkhyl): revisit this when the decision on the default [time.Duration]
+// representation is made in golang/go#71631 and/or golang/go#71664 is implemented.
+var formatDurationAsUnits = jsonv2.JoinOptions(
+	jsonv2.WithMarshalers(jsonv2.MarshalToFunc(func(e *jsontext.Encoder, t time.Duration) error {
+		return e.WriteToken(jsontext.String(t.String()))
+	})),
+)
+
 // MarshalJSONTo implements [jsonv2.MarshalerTo].
 // MarshalJSONTo implements [jsonv2.MarshalerTo].
 func (s *Snapshot) MarshalJSONTo(out *jsontext.Encoder) error {
 func (s *Snapshot) MarshalJSONTo(out *jsontext.Encoder) error {
 	data := &snapshotJSON{}
 	data := &snapshotJSON{}
@@ -159,7 +178,7 @@ func (s *Snapshot) MarshalJSONTo(out *jsontext.Encoder) error {
 		data.Summary = s.summary
 		data.Summary = s.summary
 		data.Settings = s.m
 		data.Settings = s.m
 	}
 	}
-	return jsonv2.MarshalEncode(out, data)
+	return jsonv2.MarshalEncode(out, data, formatDurationAsUnits)
 }
 }
 
 
 // UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
 // UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].

+ 12 - 0
util/syspolicy/setting/snapshot_test.go

@@ -491,6 +491,18 @@ func TestMarshalUnmarshalSnapshot(t *testing.T) {
 			snapshot: NewSnapshot(map[Key]RawItem{"ListPolicy": RawItemOf([]string{"Value1", "Value2"})}),
 			snapshot: NewSnapshot(map[Key]RawItem{"ListPolicy": RawItemOf([]string{"Value1", "Value2"})}),
 			wantJSON: `{"Settings": {"ListPolicy": {"Value": ["Value1", "Value2"]}}}`,
 			wantJSON: `{"Settings": {"ListPolicy": {"Value": ["Value1", "Value2"]}}}`,
 		},
 		},
+		{
+			name:     "Duration/Zero",
+			snapshot: NewSnapshot(map[Key]RawItem{"DurationPolicy": RawItemOf(time.Duration(0))}),
+			wantJSON: `{"Settings": {"DurationPolicy": {"Value": "0s"}}}`,
+			wantBack: NewSnapshot(map[Key]RawItem{"DurationPolicy": RawItemOf("0s")}),
+		},
+		{
+			name:     "Duration/NonZero",
+			snapshot: NewSnapshot(map[Key]RawItem{"DurationPolicy": RawItemOf(2 * time.Hour)}),
+			wantJSON: `{"Settings": {"DurationPolicy": {"Value": "2h0m0s"}}}`,
+			wantBack: NewSnapshot(map[Key]RawItem{"DurationPolicy": RawItemOf("2h0m0s")}),
+		},
 		{
 		{
 			name: "Empty/With-Summary",
 			name: "Empty/With-Summary",
 			snapshot: NewSnapshot(
 			snapshot: NewSnapshot(