Skip to content

Commit 2a2228f

Browse files
committed
util/syspolicy/setting: make setting.RawItem JSON-marshallable
We add setting.RawValue, a new type that facilitates unmarshalling JSON numbers and arrays as uint64 and []string (instead of float64 and []any) for policy setting values. We then use it to make setting.RawItem JSON-marshallable and update the tests. Updates tailscale#12687 Signed-off-by: Nick Khyl <[email protected]>
1 parent 2cc1100 commit 2a2228f

File tree

4 files changed

+336
-141
lines changed

4 files changed

+336
-141
lines changed

types/opt/value.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func ValueOf[T any](v T) Value[T] {
3636
}
3737

3838
// String implements [fmt.Stringer].
39-
func (o *Value[T]) String() string {
39+
func (o Value[T]) String() string {
4040
if !o.set {
4141
return fmt.Sprintf("(empty[%T])", o.value)
4242
}

util/syspolicy/setting/raw_item.go

Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ package setting
55

66
import (
77
"fmt"
8+
"reflect"
89

10+
jsonv2 "github.com/go-json-experiment/json"
11+
"github.com/go-json-experiment/json/jsontext"
12+
"tailscale.com/types/opt"
913
"tailscale.com/types/structs"
1014
)
1115

@@ -17,10 +21,15 @@ import (
1721
// or converted from strings, these setting types predate the typed policy
1822
// hierarchies, and must be supported at this layer.
1923
type RawItem struct {
20-
_ structs.Incomparable
21-
value any
22-
err *ErrorText
23-
origin *Origin // or nil
24+
_ structs.Incomparable
25+
data rawItemJSON
26+
}
27+
28+
// rawItemJSON holds JSON-marshallable data for [RawItem].
29+
type rawItemJSON struct {
30+
Value RawValue `json:",omitzero"`
31+
Error *ErrorText `json:",omitzero"` // or nil
32+
Origin *Origin `json:",omitzero"` // or nil
2433
}
2534

2635
// RawItemOf returns a [RawItem] with the specified value.
@@ -30,38 +39,124 @@ func RawItemOf(value any) RawItem {
3039

3140
// RawItemWith returns a [RawItem] with the specified value, error and origin.
3241
func RawItemWith(value any, err *ErrorText, origin *Origin) RawItem {
33-
return RawItem{value: value, err: err, origin: origin}
42+
return RawItem{data: rawItemJSON{Value: RawValue{opt.ValueOf(value)}, Error: err, Origin: origin}}
3443
}
3544

3645
// Value returns the value of the policy setting, or nil if the policy setting
3746
// is not configured, or an error occurred while reading it.
3847
func (i RawItem) Value() any {
39-
return i.value
48+
return i.data.Value.Get()
4049
}
4150

4251
// Error returns the error that occurred when reading the policy setting,
4352
// or nil if no error occurred.
4453
func (i RawItem) Error() error {
45-
if i.err != nil {
46-
return i.err
54+
if i.data.Error != nil {
55+
return i.data.Error
4756
}
4857
return nil
4958
}
5059

5160
// Origin returns an optional [Origin] indicating where the policy setting is
5261
// configured.
5362
func (i RawItem) Origin() *Origin {
54-
return i.origin
63+
return i.data.Origin
5564
}
5665

5766
// String implements [fmt.Stringer].
5867
func (i RawItem) String() string {
5968
var suffix string
60-
if i.origin != nil {
61-
suffix = fmt.Sprintf(" - {%v}", i.origin)
69+
if i.data.Origin != nil {
70+
suffix = fmt.Sprintf(" - {%v}", i.data.Origin)
71+
}
72+
if i.data.Error != nil {
73+
return fmt.Sprintf("Error{%q}%s", i.data.Error.Error(), suffix)
74+
}
75+
return fmt.Sprintf("%v%s", i.data.Value.Value, suffix)
76+
}
77+
78+
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
79+
func (i RawItem) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
80+
return jsonv2.MarshalEncode(out, &i.data, opts)
81+
}
82+
83+
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
84+
func (i *RawItem) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
85+
return jsonv2.UnmarshalDecode(in, &i.data, opts)
86+
}
87+
88+
// MarshalJSON implements [json.Marshaler].
89+
func (i RawItem) MarshalJSON() ([]byte, error) {
90+
return jsonv2.Marshal(i) // uses MarshalJSONV2
91+
}
92+
93+
// UnmarshalJSON implements [json.Unmarshaler].
94+
func (i *RawItem) UnmarshalJSON(b []byte) error {
95+
return jsonv2.Unmarshal(b, i) // uses UnmarshalJSONV2
96+
}
97+
98+
// RawValue represents a raw policy setting value read from a policy store.
99+
// It is JSON-marshallable and facilitates unmarshalling of JSON values
100+
// into corresponding policy setting types, with special handling for JSON numbers
101+
// (unmarshalled as float64) and JSON string arrays (unmarshalled as []string).
102+
// See also [RawValue.UnmarshalJSONV2].
103+
type RawValue struct {
104+
opt.Value[any]
105+
}
106+
107+
// RawValueType is a constraint that permits raw setting value types.
108+
type RawValueType interface {
109+
bool | uint64 | string | []string
110+
}
111+
112+
// RawValueOf returns a new [RawValue] holding the specified value.
113+
func RawValueOf[T RawValueType](v T) RawValue {
114+
return RawValue{opt.ValueOf[any](v)}
115+
}
116+
117+
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
118+
func (v RawValue) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
119+
return jsonv2.MarshalEncode(out, v.Value, opts)
120+
}
121+
122+
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2] by attempting to unmarshal
123+
// a JSON value as one of the supported policy setting value types (bool, string, uint64, or []string),
124+
// based on the JSON value type. It fails if the JSON value is an object, if it's a JSON number that
125+
// cannot be represented as a uint64, or if a JSON array contains anything other than strings.
126+
func (v *RawValue) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
127+
var valPtr any
128+
switch k := in.PeekKind(); k {
129+
case 't', 'f':
130+
valPtr = new(bool)
131+
case '"':
132+
valPtr = new(string)
133+
case '0':
134+
valPtr = new(uint64) // unmarshal JSON numbers as uint64
135+
case '[', 'n':
136+
valPtr = new([]string) // unmarshal arrays as string slices
137+
case '{':
138+
return fmt.Errorf("unexpected token: %v", k)
139+
default:
140+
panic("unreachable")
62141
}
63-
if i.err != nil {
64-
return fmt.Sprintf("Error{%q}%s", i.err.Error(), suffix)
142+
if err := jsonv2.UnmarshalDecode(in, valPtr, opts); err != nil {
143+
v.Value.Clear()
144+
return err
65145
}
66-
return fmt.Sprintf("%v%s", i.value, suffix)
146+
value := reflect.ValueOf(valPtr).Elem().Interface()
147+
v.Value = opt.ValueOf(value)
148+
return nil
149+
}
150+
151+
// MarshalJSON implements [json.Marshaler].
152+
func (v RawValue) MarshalJSON() ([]byte, error) {
153+
return jsonv2.Marshal(v) // uses MarshalJSONV2
67154
}
155+
156+
// UnmarshalJSON implements [json.Unmarshaler].
157+
func (v *RawValue) UnmarshalJSON(b []byte) error {
158+
return jsonv2.Unmarshal(b, v) // uses UnmarshalJSONV2
159+
}
160+
161+
// RawValues is a map of keyed setting values that can be read from a JSON.
162+
type RawValues map[Key]RawValue
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package setting
5+
6+
import (
7+
"math"
8+
"reflect"
9+
"strconv"
10+
"testing"
11+
12+
jsonv2 "github.com/go-json-experiment/json"
13+
)
14+
15+
func TestMarshalUnmarshalRawValue(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
json string
19+
want RawValue
20+
wantErr bool
21+
}{
22+
{
23+
name: "Bool/True",
24+
json: `true`,
25+
want: RawValueOf(true),
26+
},
27+
{
28+
name: "Bool/False",
29+
json: `false`,
30+
want: RawValueOf(false),
31+
},
32+
{
33+
name: "String/Empty",
34+
json: `""`,
35+
want: RawValueOf(""),
36+
},
37+
{
38+
name: "String/NonEmpty",
39+
json: `"Test"`,
40+
want: RawValueOf("Test"),
41+
},
42+
{
43+
name: "StringSlice/Null",
44+
json: `null`,
45+
want: RawValueOf([]string(nil)),
46+
},
47+
{
48+
name: "StringSlice/Empty",
49+
json: `[]`,
50+
want: RawValueOf([]string{}),
51+
},
52+
{
53+
name: "StringSlice/NonEmpty",
54+
json: `["A", "B", "C"]`,
55+
want: RawValueOf([]string{"A", "B", "C"}),
56+
},
57+
{
58+
name: "StringSlice/NonStrings",
59+
json: `[1, 2, 3]`,
60+
wantErr: true,
61+
},
62+
{
63+
name: "Number/Integer/0",
64+
json: `0`,
65+
want: RawValueOf(uint64(0)),
66+
},
67+
{
68+
name: "Number/Integer/1",
69+
json: `1`,
70+
want: RawValueOf(uint64(1)),
71+
},
72+
{
73+
name: "Number/Integer/MaxUInt64",
74+
json: strconv.FormatUint(math.MaxUint64, 10),
75+
want: RawValueOf(uint64(math.MaxUint64)),
76+
},
77+
{
78+
name: "Number/Integer/Negative",
79+
json: `-1`,
80+
wantErr: true,
81+
},
82+
{
83+
name: "Object",
84+
json: `{}`,
85+
wantErr: true,
86+
},
87+
}
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
var got RawValue
91+
gotErr := jsonv2.Unmarshal([]byte(tt.json), &got)
92+
if (gotErr != nil) != tt.wantErr {
93+
t.Fatalf("Error: got %v; want %v", gotErr, tt.wantErr)
94+
}
95+
96+
if !tt.wantErr && !reflect.DeepEqual(got, tt.want) {
97+
t.Fatalf("Value: got %v; want %v", got, tt.want)
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)