Skip to content

Commit 540e4c8

Browse files
committed
util/syspolicy/setting: make setting.Snapshot JSON-marshallable
We make setting.Snapshot JSON-marshallable in preparation for returning it from the LocalAPI. Updates tailscale#12687 Signed-off-by: Nick Khyl <[email protected]>
1 parent 2a2228f commit 540e4c8

File tree

2 files changed

+180
-0
lines changed

2 files changed

+180
-0
lines changed

util/syspolicy/setting/snapshot.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
package setting
55

66
import (
7+
"errors"
78
"iter"
89
"maps"
910
"slices"
1011
"strings"
1112

13+
jsonv2 "github.com/go-json-experiment/json"
14+
"github.com/go-json-experiment/json/jsontext"
1215
xmaps "golang.org/x/exp/maps"
1316
"tailscale.com/util/deephash"
1417
)
@@ -65,6 +68,9 @@ func (s *Snapshot) GetSetting(k Key) (setting RawItem, ok bool) {
6568

6669
// Equal reports whether s and s2 are equal.
6770
func (s *Snapshot) Equal(s2 *Snapshot) bool {
71+
if s == s2 {
72+
return true
73+
}
6874
if !s.EqualItems(s2) {
6975
return false
7076
}
@@ -135,6 +141,45 @@ func (s *Snapshot) String() string {
135141
return sb.String()
136142
}
137143

144+
// snapshotJSON holds JSON-marshallable data for [Snapshot].
145+
type snapshotJSON struct {
146+
Summary Summary `json:",omitzero"`
147+
Settings map[Key]RawItem `json:",omitempty"`
148+
}
149+
150+
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
151+
func (s *Snapshot) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
152+
data := &snapshotJSON{}
153+
if s != nil {
154+
data.Summary = s.summary
155+
data.Settings = s.m
156+
}
157+
return jsonv2.MarshalEncode(out, data, opts)
158+
}
159+
160+
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
161+
func (s *Snapshot) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
162+
if s == nil {
163+
return errors.New("s must not be nil")
164+
}
165+
data := &snapshotJSON{}
166+
if err := jsonv2.UnmarshalDecode(in, data, opts); err != nil {
167+
return err
168+
}
169+
*s = Snapshot{m: data.Settings, sig: deephash.Hash(&data.Settings), summary: data.Summary}
170+
return nil
171+
}
172+
173+
// MarshalJSON implements [json.Marshaler].
174+
func (s *Snapshot) MarshalJSON() ([]byte, error) {
175+
return jsonv2.Marshal(s) // uses MarshalJSONV2
176+
}
177+
178+
// UnmarshalJSON implements [json.Unmarshaler].
179+
func (s *Snapshot) UnmarshalJSON(b []byte) error {
180+
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
181+
}
182+
138183
// MergeSnapshots returns a [Snapshot] that contains all [RawItem]s
139184
// from snapshot1 and snapshot2 and the [Summary] with the narrower [PolicyScope].
140185
// If there's a conflict between policy settings in the two snapshots,

util/syspolicy/setting/snapshot_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
package setting
55

66
import (
7+
"cmp"
8+
"encoding/json"
79
"testing"
810
"time"
11+
12+
jsonv2 "github.com/go-json-experiment/json"
13+
"tailscale.com/util/syspolicy/internal"
914
)
1015

1116
func TestMergeSnapshots(t *testing.T) {
@@ -432,3 +437,133 @@ Setting3 = user-decides`,
432437
})
433438
}
434439
}
440+
441+
func TestMarshalUnmarshalSnapshot(t *testing.T) {
442+
tests := []struct {
443+
name string
444+
snapshot *Snapshot
445+
wantJSON string
446+
wantBack *Snapshot
447+
}{
448+
{
449+
name: "Nil",
450+
snapshot: (*Snapshot)(nil),
451+
wantJSON: "null",
452+
wantBack: NewSnapshot(nil),
453+
},
454+
{
455+
name: "Zero",
456+
snapshot: &Snapshot{},
457+
wantJSON: "{}",
458+
},
459+
{
460+
name: "Bool/True",
461+
snapshot: NewSnapshot(map[Key]RawItem{"BoolPolicy": RawItemOf(true)}),
462+
wantJSON: `{"Settings": {"BoolPolicy": {"Value": true}}}`,
463+
},
464+
{
465+
name: "Bool/False",
466+
snapshot: NewSnapshot(map[Key]RawItem{"BoolPolicy": RawItemOf(false)}),
467+
wantJSON: `{"Settings": {"BoolPolicy": {"Value": false}}}`,
468+
},
469+
{
470+
name: "String/Non-Empty",
471+
snapshot: NewSnapshot(map[Key]RawItem{"StringPolicy": RawItemOf("StringValue")}),
472+
wantJSON: `{"Settings": {"StringPolicy": {"Value": "StringValue"}}}`,
473+
},
474+
{
475+
name: "String/Empty",
476+
snapshot: NewSnapshot(map[Key]RawItem{"StringPolicy": RawItemOf("")}),
477+
wantJSON: `{"Settings": {"StringPolicy": {"Value": ""}}}`,
478+
},
479+
{
480+
name: "Integer/NonZero",
481+
snapshot: NewSnapshot(map[Key]RawItem{"IntPolicy": RawItemOf(uint64(42))}),
482+
wantJSON: `{"Settings": {"IntPolicy": {"Value": 42}}}`,
483+
},
484+
{
485+
name: "Integer/Zero",
486+
snapshot: NewSnapshot(map[Key]RawItem{"IntPolicy": RawItemOf(uint64(0))}),
487+
wantJSON: `{"Settings": {"IntPolicy": {"Value": 0}}}`,
488+
},
489+
{
490+
name: "String-List",
491+
snapshot: NewSnapshot(map[Key]RawItem{"ListPolicy": RawItemOf([]string{"Value1", "Value2"})}),
492+
wantJSON: `{"Settings": {"ListPolicy": {"Value": ["Value1", "Value2"]}}}`,
493+
},
494+
{
495+
name: "Empty/With-Summary",
496+
snapshot: NewSnapshot(
497+
map[Key]RawItem{},
498+
SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)),
499+
),
500+
wantJSON: `{"Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"}}`,
501+
},
502+
{
503+
name: "Setting/With-Summary",
504+
snapshot: NewSnapshot(
505+
map[Key]RawItem{"PolicySetting": RawItemOf(uint64(42))},
506+
SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)),
507+
),
508+
wantJSON: `{
509+
"Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"},
510+
"Settings": {"PolicySetting": {"Value": 42}}
511+
}`,
512+
},
513+
{
514+
name: "Settings/With-Origins",
515+
snapshot: NewSnapshot(
516+
map[Key]RawItem{
517+
"SettingA": RawItemWith(uint64(42), nil, NewNamedOrigin("SourceA", DeviceScope)),
518+
"SettingB": RawItemWith("B", nil, NewNamedOrigin("SourceB", CurrentProfileScope)),
519+
"SettingC": RawItemWith(true, nil, NewNamedOrigin("SourceC", CurrentUserScope)),
520+
},
521+
),
522+
wantJSON: `{
523+
"Settings": {
524+
"SettingA": {"Value": 42, "Origin": {"Name": "SourceA", "Scope": "Device"}},
525+
"SettingB": {"Value": "B", "Origin": {"Name": "SourceB", "Scope": "Profile"}},
526+
"SettingC": {"Value": true, "Origin": {"Name": "SourceC", "Scope": "User"}}
527+
}
528+
}`,
529+
},
530+
}
531+
532+
for _, tt := range tests {
533+
t.Run(tt.name, func(t *testing.T) {
534+
doTest := func(t *testing.T, useJSONv2 bool) {
535+
var gotJSON []byte
536+
var err error
537+
if useJSONv2 {
538+
gotJSON, err = jsonv2.Marshal(tt.snapshot)
539+
} else {
540+
gotJSON, err = json.Marshal(tt.snapshot)
541+
}
542+
if err != nil {
543+
t.Fatal(err)
544+
}
545+
546+
if got, want, equal := internal.EqualJSONForTest(t, gotJSON, []byte(tt.wantJSON)); !equal {
547+
t.Errorf("JSON: got %s; want %s", got, want)
548+
}
549+
550+
gotBack := &Snapshot{}
551+
if useJSONv2 {
552+
err = jsonv2.Unmarshal(gotJSON, &gotBack)
553+
} else {
554+
err = json.Unmarshal(gotJSON, &gotBack)
555+
}
556+
if err != nil {
557+
t.Fatal(err)
558+
}
559+
560+
if wantBack := cmp.Or(tt.wantBack, tt.snapshot); !gotBack.Equal(wantBack) {
561+
t.Errorf("Snapshot: got %+v; want %+v", gotBack, wantBack)
562+
}
563+
}
564+
565+
t.Run("json", func(t *testing.T) { doTest(t, false) })
566+
t.Run("jsonv2", func(t *testing.T) { doTest(t, true) })
567+
})
568+
}
569+
}

0 commit comments

Comments
 (0)