From af3d3c433b67b1a3929376477a17f61a4b3ae2bd Mon Sep 17 00:00:00 2001 From: Nick Khyl Date: Tue, 16 Jul 2024 17:27:37 -0500 Subject: [PATCH] types/prefs: add a package containing generic preference types This adds a new package containing generic types to be used for defining preference hierarchies. These include prefs.Item, prefs.List, prefs.StructList, and prefs.StructMap. Each of these types represents a configurable preference, holding the preference's state, value, and metadata. The metadata includes the default value (if it differs from the zero value of the Go type) and flags indicating whether a preference is managed via syspolicy or is hidden/read-only for another reason. This information can be marshaled and sent to the GUI, CLI and web clients as a source of truth regarding preference configuration, management, and visibility/mutability states. We plan to use these types to define device preferences, such as the updater preferences, the permission mode to be used on Windows with #tailscale/corp#18342, and certain global options that are currently exposed as tailscaled flags. We also aim to eventually use these types for profile-local preferences in ipn.Prefs and and as a replacement for ipn.MaskedPrefs. The generic preference types are compatible with the tailscale.com/cmd/viewer and tailscale.com/cmd/cloner utilities. Updates #12736 Signed-off-by: Nick Khyl --- types/prefs/item.go | 178 +++++ types/prefs/list.go | 183 +++++ types/prefs/map.go | 159 +++++ types/prefs/options.go | 22 + types/prefs/prefs.go | 179 +++++ types/prefs/prefs_clone_test.go | 130 ++++ .../prefs_example/prefs_example_clone.go | 99 +++ .../prefs/prefs_example/prefs_example_view.go | 239 +++++++ types/prefs/prefs_example/prefs_test.go | 140 ++++ types/prefs/prefs_example/prefs_types.go | 166 +++++ types/prefs/prefs_test.go | 670 ++++++++++++++++++ types/prefs/prefs_view_test.go | 342 +++++++++ types/prefs/struct_list.go | 195 +++++ types/prefs/struct_map.go | 175 +++++ 14 files changed, 2877 insertions(+) create mode 100644 types/prefs/item.go create mode 100644 types/prefs/list.go create mode 100644 types/prefs/map.go create mode 100644 types/prefs/options.go create mode 100644 types/prefs/prefs.go create mode 100644 types/prefs/prefs_clone_test.go create mode 100644 types/prefs/prefs_example/prefs_example_clone.go create mode 100644 types/prefs/prefs_example/prefs_example_view.go create mode 100644 types/prefs/prefs_example/prefs_test.go create mode 100644 types/prefs/prefs_example/prefs_types.go create mode 100644 types/prefs/prefs_test.go create mode 100644 types/prefs/prefs_view_test.go create mode 100644 types/prefs/struct_list.go create mode 100644 types/prefs/struct_map.go diff --git a/types/prefs/item.go b/types/prefs/item.go new file mode 100644 index 000000000..103204147 --- /dev/null +++ b/types/prefs/item.go @@ -0,0 +1,178 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "fmt" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "tailscale.com/types/opt" + "tailscale.com/types/ptr" + "tailscale.com/types/views" + "tailscale.com/util/must" +) + +// Item is a single preference item that can be configured. +// T must either be an immutable type or implement the [views.ViewCloner] interface. +type Item[T any] struct { + preference[T] +} + +// ItemOf returns an [Item] configured with the specified value and [Options]. +func ItemOf[T any](v T, opts ...Options) Item[T] { + return Item[T]{preferenceOf(opt.ValueOf(must.Get(deepClone(v))), opts...)} +} + +// ItemWithOpts returns an unconfigured [Item] with the specified [Options]. +func ItemWithOpts[T any](opts ...Options) Item[T] { + return Item[T]{preferenceOf(opt.Value[T]{}, opts...)} +} + +// SetValue configures the preference with the specified value. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (i *Item[T]) SetValue(val T) error { + return i.preference.SetValue(must.Get(deepClone(val))) +} + +// SetManagedValue configures the preference with the specified value +// and marks the preference as managed. +func (i *Item[T]) SetManagedValue(val T) { + i.preference.SetManagedValue(must.Get(deepClone(val))) +} + +// Clone returns a copy of i that aliases no memory with i. +// It is a runtime error to call [Item.Clone] if T contains pointers +// but does not implement [views.Cloner]. +func (i Item[T]) Clone() *Item[T] { + res := ptr.To(i) + if v, ok := i.ValueOk(); ok { + res.s.Value.Set(must.Get(deepClone(v))) + } + return res +} + +// Equal reports whether i and i2 are equal. +// If the template type T implements an Equal(T) bool method, it will be used +// instead of the == operator for value comparison. +// If T is not comparable, it reports false. +func (i Item[T]) Equal(i2 Item[T]) bool { + if i.s.Metadata != i2.s.Metadata { + return false + } + return i.s.Value.Equal(i2.s.Value) +} + +func deepClone[T any](v T) (T, error) { + if c, ok := any(v).(views.Cloner[T]); ok { + return c.Clone(), nil + } + if !views.ContainsPointers[T]() { + return v, nil + } + var zero T + return zero, fmt.Errorf("%T contains pointers, but does not implement Clone", v) +} + +// ItemView is a read-only view of an [Item][T], where T is a mutable type +// implementing [views.ViewCloner]. +type ItemView[T views.ViewCloner[T, V], V views.StructView[T]] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *Item[T] +} + +// ItemViewOf returns a read-only view of i. +// It is used by [tailscale.com/cmd/viewer]. +func ItemViewOf[T views.ViewCloner[T, V], V views.StructView[T]](i *Item[T]) ItemView[T, V] { + return ItemView[T, V]{i} +} + +// Valid reports whether the underlying [Item] is non-nil. +func (iv ItemView[T, V]) Valid() bool { + return iv.ж != nil +} + +// AsStruct implements [views.StructView] by returning a clone of the preference +// which aliases no memory with the original. +func (iv ItemView[T, V]) AsStruct() *Item[T] { + if iv.ж == nil { + return nil + } + return iv.ж.Clone() +} + +// IsSet reports whether the preference has a value set. +func (iv ItemView[T, V]) IsSet() bool { + return iv.ж.IsSet() +} + +// Value returns a read-only view of the value if the preference has a value set. +// Otherwise, it returns a read-only view of its default value. +func (iv ItemView[T, V]) Value() V { + return iv.ж.Value().View() +} + +// ValueOk returns a read-only view of the value and true if the preference has a value set. +// Otherwise, it returns an invalid view and false. +func (iv ItemView[T, V]) ValueOk() (val V, ok bool) { + if val, ok := iv.ж.ValueOk(); ok { + return val.View(), true + } + return val, false +} + +// DefaultValue returns a read-only view of the default value of the preference. +func (iv ItemView[T, V]) DefaultValue() V { + return iv.ж.DefaultValue().View() +} + +// IsManaged reports whether the preference is managed via MDM, Group Policy, or similar means. +func (iv ItemView[T, V]) IsManaged() bool { + return iv.ж.IsManaged() +} + +// IsReadOnly reports whether the preference is read-only and cannot be changed by user. +func (iv ItemView[T, V]) IsReadOnly() bool { + return iv.ж.IsReadOnly() +} + +// Equal reports whether iv and iv2 are equal. +func (iv ItemView[T, V]) Equal(iv2 ItemView[T, V]) bool { + if !iv.Valid() && !iv2.Valid() { + return true + } + if iv.Valid() != iv2.Valid() { + return false + } + return iv.ж.Equal(*iv2.ж) +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (iv ItemView[T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return iv.ж.MarshalJSONV2(out, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (iv *ItemView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + var x Item[T] + if err := x.UnmarshalJSONV2(in, opts); err != nil { + return err + } + iv.ж = &x + return nil +} + +// MarshalJSON implements [json.Marshaler]. +func (iv ItemView[T, V]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(iv) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (iv *ItemView[T, V]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, iv) // uses UnmarshalJSONV2 +} diff --git a/types/prefs/list.go b/types/prefs/list.go new file mode 100644 index 000000000..9830e79de --- /dev/null +++ b/types/prefs/list.go @@ -0,0 +1,183 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "net/netip" + "slices" + "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "golang.org/x/exp/constraints" + "tailscale.com/types/opt" + "tailscale.com/types/ptr" + "tailscale.com/types/views" +) + +// BasicType is a constraint that allows types whose underlying type is a predeclared +// boolean, numeric, or string type. +type BasicType interface { + ~bool | constraints.Integer | constraints.Float | constraints.Complex | ~string +} + +// ImmutableType is a constraint that allows [BasicType]s and certain well-known immutable types. +type ImmutableType interface { + BasicType | time.Time | netip.Addr | netip.Prefix | netip.AddrPort +} + +// List is a preference type that holds zero or more values of an [ImmutableType] T. +type List[T ImmutableType] struct { + preference[[]T] +} + +// ListOf returns a [List] configured with the specified value and [Options]. +func ListOf[T ImmutableType](v []T, opts ...Options) List[T] { + return List[T]{preferenceOf(opt.ValueOf(cloneSlice(v)), opts...)} +} + +// ListWithOpts returns an unconfigured [List] with the specified [Options]. +func ListWithOpts[T ImmutableType](opts ...Options) List[T] { + return List[T]{preferenceOf(opt.Value[[]T]{}, opts...)} +} + +// SetValue configures the preference with the specified value. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (l *List[T]) SetValue(val []T) error { + return l.preference.SetValue(cloneSlice(val)) +} + +// SetManagedValue configures the preference with the specified value +// and marks the preference as managed. +func (l *List[T]) SetManagedValue(val []T) { + l.preference.SetManagedValue(cloneSlice(val)) +} + +// View returns a read-only view of l. +func (l *List[T]) View() ListView[T] { + return ListView[T]{l} +} + +// Clone returns a copy of l that aliases no memory with l. +func (l List[T]) Clone() *List[T] { + res := ptr.To(l) + if v, ok := l.s.Value.GetOk(); ok { + res.s.Value.Set(append(v[:0:0], v...)) + } + return res +} + +// Equal reports whether l and l2 are equal. +func (l List[T]) Equal(l2 List[T]) bool { + if l.s.Metadata != l2.s.Metadata { + return false + } + v1, ok1 := l.s.Value.GetOk() + v2, ok2 := l2.s.Value.GetOk() + if ok1 != ok2 { + return false + } + return !ok1 || slices.Equal(v1, v2) +} + +func cloneSlice[T ImmutableType](s []T) []T { + c := make([]T, len(s)) + copy(c, s) + return c +} + +// ListView is a read-only view of a [List]. +type ListView[T ImmutableType] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *List[T] +} + +// Valid reports whether the underlying [List] is non-nil. +func (lv ListView[T]) Valid() bool { + return lv.ж != nil +} + +// AsStruct implements [views.StructView] by returning a clone of the [List] +// which aliases no memory with the original. +func (lv ListView[T]) AsStruct() *List[T] { + if lv.ж == nil { + return nil + } + return lv.ж.Clone() +} + +// IsSet reports whether the preference has a value set. +func (lv ListView[T]) IsSet() bool { + return lv.ж.IsSet() +} + +// Value returns a read-only view of the value if the preference has a value set. +// Otherwise, it returns a read-only view of its default value. +func (lv ListView[T]) Value() views.Slice[T] { + return views.SliceOf(lv.ж.Value()) +} + +// ValueOk returns a read-only view of the value and true if the preference has a value set. +// Otherwise, it returns an invalid view and false. +func (lv ListView[T]) ValueOk() (val views.Slice[T], ok bool) { + if v, ok := lv.ж.ValueOk(); ok { + return views.SliceOf(v), true + } + return views.Slice[T]{}, false +} + +// DefaultValue returns a read-only view of the default value of the preference. +func (lv ListView[T]) DefaultValue() views.Slice[T] { + return views.SliceOf(lv.ж.DefaultValue()) +} + +// IsManaged reports whether the preference is managed via MDM, Group Policy, or similar means. +func (lv ListView[T]) IsManaged() bool { + return lv.ж.IsManaged() +} + +// IsReadOnly reports whether the preference is read-only and cannot be changed by user. +func (lv ListView[T]) IsReadOnly() bool { + return lv.ж.IsReadOnly() +} + +// Equal reports whether lv and lv2 are equal. +func (lv ListView[T]) Equal(lv2 ListView[T]) bool { + if !lv.Valid() && !lv2.Valid() { + return true + } + if lv.Valid() != lv2.Valid() { + return false + } + return lv.ж.Equal(*lv2.ж) +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (lv ListView[T]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return lv.ж.MarshalJSONV2(out, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (lv *ListView[T]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + var x List[T] + if err := x.UnmarshalJSONV2(in, opts); err != nil { + return err + } + lv.ж = &x + return nil +} + +// MarshalJSON implements [json.Marshaler]. +func (lv ListView[T]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(lv) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (lv *ListView[T]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONV2 +} diff --git a/types/prefs/map.go b/types/prefs/map.go new file mode 100644 index 000000000..2bd32bfbd --- /dev/null +++ b/types/prefs/map.go @@ -0,0 +1,159 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "maps" + "net/netip" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "golang.org/x/exp/constraints" + "tailscale.com/types/opt" + "tailscale.com/types/ptr" + "tailscale.com/types/views" +) + +// MapKeyType is a constraint allowing types that can be used as [Map] and [StructMap] keys. +// To satisfy this requirement, a type must be comparable and must encode as a JSON string. +// See [jsonv2.Marshal] for more details. +type MapKeyType interface { + ~string | constraints.Integer | netip.Addr | netip.Prefix | netip.AddrPort +} + +// Map is a preference type that holds immutable key-value pairs. +type Map[K MapKeyType, V ImmutableType] struct { + preference[map[K]V] +} + +// MapOf returns a map configured with the specified value and [Options]. +func MapOf[K MapKeyType, V ImmutableType](v map[K]V, opts ...Options) Map[K, V] { + return Map[K, V]{preferenceOf(opt.ValueOf(v), opts...)} +} + +// MapWithOpts returns an unconfigured [Map] with the specified [Options]. +func MapWithOpts[K MapKeyType, V ImmutableType](opts ...Options) Map[K, V] { + return Map[K, V]{preferenceOf(opt.Value[map[K]V]{}, opts...)} +} + +// View returns a read-only view of m. +func (m *Map[K, V]) View() MapView[K, V] { + return MapView[K, V]{m} +} + +// Clone returns a copy of m that aliases no memory with m. +func (m Map[K, V]) Clone() *Map[K, V] { + res := ptr.To(m) + if v, ok := m.s.Value.GetOk(); ok { + res.s.Value.Set(maps.Clone(v)) + } + return res +} + +// Equal reports whether m and m2 are equal. +func (m Map[K, V]) Equal(m2 Map[K, V]) bool { + if m.s.Metadata != m2.s.Metadata { + return false + } + v1, ok1 := m.s.Value.GetOk() + v2, ok2 := m2.s.Value.GetOk() + if ok1 != ok2 { + return false + } + return !ok1 || maps.Equal(v1, v2) +} + +// MapView is a read-only view of a [Map]. +type MapView[K MapKeyType, V ImmutableType] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *Map[K, V] +} + +// Valid reports whether the underlying [Map] is non-nil. +func (mv MapView[K, V]) Valid() bool { + return mv.ж != nil +} + +// AsStruct implements [views.StructView] by returning a clone of the [Map] +// which aliases no memory with the original. +func (mv MapView[K, V]) AsStruct() *Map[K, V] { + if mv.ж == nil { + return nil + } + return mv.ж.Clone() +} + +// IsSet reports whether the preference has a value set. +func (mv MapView[K, V]) IsSet() bool { + return mv.ж.IsSet() +} + +// Value returns a read-only view of the value if the preference has a value set. +// Otherwise, it returns a read-only view of its default value. +func (mv MapView[K, V]) Value() views.Map[K, V] { + return views.MapOf(mv.ж.Value()) +} + +// ValueOk returns a read-only view of the value and true if the preference has a value set. +// Otherwise, it returns an invalid view and false. +func (mv MapView[K, V]) ValueOk() (val views.Map[K, V], ok bool) { + if v, ok := mv.ж.ValueOk(); ok { + return views.MapOf(v), true + } + return views.Map[K, V]{}, false +} + +// DefaultValue returns a read-only view of the default value of the preference. +func (mv MapView[K, V]) DefaultValue() views.Map[K, V] { + return views.MapOf(mv.ж.DefaultValue()) +} + +// Managed reports whether the preference is managed via MDM, Group Policy, or similar means. +func (mv MapView[K, V]) Managed() bool { + return mv.ж.IsManaged() +} + +// ReadOnly reports whether the preference is read-only and cannot be changed by user. +func (mv MapView[K, V]) ReadOnly() bool { + return mv.ж.IsReadOnly() +} + +// Equal reports whether mv and mv2 are equal. +func (mv MapView[K, V]) Equal(mv2 MapView[K, V]) bool { + if !mv.Valid() && !mv2.Valid() { + return true + } + if mv.Valid() != mv2.Valid() { + return false + } + return mv.ж.Equal(*mv2.ж) +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (mv MapView[K, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return mv.ж.MarshalJSONV2(out, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (mv *MapView[K, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + var x Map[K, V] + if err := x.UnmarshalJSONV2(in, opts); err != nil { + return err + } + mv.ж = &x + return nil +} + +// MarshalJSON implements [json.Marshaler]. +func (mv MapView[K, V]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(mv) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (mv *MapView[K, V]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONV2 +} diff --git a/types/prefs/options.go b/types/prefs/options.go new file mode 100644 index 000000000..3769b784b --- /dev/null +++ b/types/prefs/options.go @@ -0,0 +1,22 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +// Options are used to configure additional parameters of a preference. +type Options func(s *metadata) + +var ( + // ReadOnly is an option that marks preference as read-only. + ReadOnly Options = markReadOnly + // Managed is an option that marks preference as managed. + Managed Options = markManaged +) + +func markReadOnly(s *metadata) { + s.ReadOnly = true +} + +func markManaged(s *metadata) { + s.Managed = true +} diff --git a/types/prefs/prefs.go b/types/prefs/prefs.go new file mode 100644 index 000000000..3bbd237fe --- /dev/null +++ b/types/prefs/prefs.go @@ -0,0 +1,179 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package prefs contains types and functions to work with arbitrary +// preference hierarchies. +// +// Specifically, the package provides [Item], [List], [Map], [StructList] and [StructMap] +// types which represent individual preferences in a user-defined prefs struct. +// A valid prefs struct must contain one or more exported fields of the preference types, +// either directly or within nested structs, but not pointers to these types. +// Additionally to preferences, a prefs struct may contain any number of +// non-preference fields that will be marshalled and unmarshalled but are +// otherwise ignored by the prefs package. +// +// The preference types are compatible with the [tailscale.com/cmd/viewer] and +// [tailscale.com/cmd/cloner] utilities. It is recommended to generate a read-only view +// of the user-defined prefs structure and use it in place of prefs whenever the prefs +// should not be modified. +package prefs + +import ( + "errors" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "tailscale.com/types/opt" +) + +var ( + // ErrManaged is the error returned when attempting to modify a managed preference. + ErrManaged = errors.New("cannot modify a managed preference") + // ErrReadOnly is the error returned when attempting to modify a readonly preference. + ErrReadOnly = errors.New("cannot modify a readonly preference") +) + +// metadata holds type-agnostic preference metadata. +type metadata struct { + // Managed indicates whether the preference is managed via MDM, Group Policy, or other means. + Managed bool `json:",omitzero"` + + // ReadOnly indicates whether the preference is read-only due to any other reasons, + // such as user's access rights. + ReadOnly bool `json:",omitzero"` +} + +// serializable is a JSON-serializable preference data. +type serializable[T any] struct { + // Value is an optional preference value that is set when the preference is + // configured by the user or managed by an admin. + Value opt.Value[T] `json:",omitzero"` + // Default is the default preference value to be used + // when the preference has not been configured. + Default T `json:",omitzero"` + // Metadata is any additional type-agnostic preference metadata to be serialized. + Metadata metadata `json:",inline"` +} + +// preference is an embeddable type that provides a common implementation for +// concrete preference types, such as [Item], [List], [Map], [StructList] and [StructMap]. +type preference[T any] struct { + s serializable[T] +} + +// preferenceOf returns a preference with the specified value and/or [Options]. +func preferenceOf[T any](v opt.Value[T], opts ...Options) preference[T] { + var m metadata + for _, o := range opts { + o(&m) + } + return preference[T]{serializable[T]{Value: v, Metadata: m}} +} + +// IsSet reports whether p has a value set. +func (p preference[T]) IsSet() bool { + return p.s.Value.IsSet() +} + +// Value returns the value of p if the preference has a value set. +// Otherwise, it returns its default value. +func (p preference[T]) Value() T { + val, _ := p.ValueOk() + return val +} + +// ValueOk returns the value of p and true if the preference has a value set. +// Otherwise, it returns its default value and false. +func (p preference[T]) ValueOk() (val T, ok bool) { + if val, ok = p.s.Value.GetOk(); ok { + return val, true + } + return p.DefaultValue(), false +} + +// SetValue configures the preference with the specified value. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (p *preference[T]) SetValue(val T) error { + switch { + case p.s.Metadata.Managed: + return ErrManaged + case p.s.Metadata.ReadOnly: + return ErrReadOnly + default: + p.s.Value.Set(val) + return nil + } +} + +// ClearValue resets the preference to an unconfigured state. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (p *preference[T]) ClearValue() error { + switch { + case p.s.Metadata.Managed: + return ErrManaged + case p.s.Metadata.ReadOnly: + return ErrReadOnly + default: + p.s.Value.Clear() + return nil + } +} + +// DefaultValue returns the default value of p. +func (p preference[T]) DefaultValue() T { + return p.s.Default +} + +// SetDefaultValue sets the default value of p. +func (p *preference[T]) SetDefaultValue(def T) { + p.s.Default = def +} + +// IsManaged reports whether p is managed via MDM, Group Policy, or similar means. +func (p preference[T]) IsManaged() bool { + return p.s.Metadata.Managed +} + +// SetManagedValue configures the preference with the specified value +// and marks the preference as managed. +func (p *preference[T]) SetManagedValue(val T) { + p.s.Value.Set(val) + p.s.Metadata.Managed = true +} + +// ClearManaged clears the managed flag of the preference without altering its value. +func (p *preference[T]) ClearManaged() { + p.s.Metadata.Managed = false +} + +// IsReadOnly reports whether p is read-only and cannot be changed by user. +func (p preference[T]) IsReadOnly() bool { + return p.s.Metadata.ReadOnly || p.s.Metadata.Managed +} + +// SetReadOnly sets the read-only status of p, preventing changes by a user if set to true. +func (p *preference[T]) SetReadOnly(readonly bool) { + p.s.Metadata.ReadOnly = readonly +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (p preference[T]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return jsonv2.MarshalEncode(out, &p.s, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (p *preference[T]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + return jsonv2.UnmarshalDecode(in, &p.s, opts) +} + +// MarshalJSON implements [json.Marshaler]. +func (p preference[T]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(p) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (p *preference[T]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2 +} diff --git a/types/prefs/prefs_clone_test.go b/types/prefs/prefs_clone_test.go new file mode 100644 index 000000000..2a03fba8b --- /dev/null +++ b/types/prefs/prefs_clone_test.go @@ -0,0 +1,130 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. + +package prefs + +import ( + "net/netip" + + "tailscale.com/types/ptr" +) + +// Clone makes a deep copy of TestPrefs. +// The result aliases no memory with the original. +func (src *TestPrefs) Clone() *TestPrefs { + if src == nil { + return nil + } + dst := new(TestPrefs) + *dst = *src + dst.StringSlice = *src.StringSlice.Clone() + dst.IntSlice = *src.IntSlice.Clone() + dst.StringStringMap = *src.StringStringMap.Clone() + dst.IntStringMap = *src.IntStringMap.Clone() + dst.AddrIntMap = *src.AddrIntMap.Clone() + dst.Bundle1 = *src.Bundle1.Clone() + dst.Bundle2 = *src.Bundle2.Clone() + dst.Generic = *src.Generic.Clone() + dst.BundleList = *src.BundleList.Clone() + dst.StringBundleMap = *src.StringBundleMap.Clone() + dst.IntBundleMap = *src.IntBundleMap.Clone() + dst.AddrBundleMap = *src.AddrBundleMap.Clone() + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestPrefsCloneNeedsRegeneration = TestPrefs(struct { + Int32Item Item[int32] + UInt64Item Item[uint64] + StringItem1 Item[string] + StringItem2 Item[string] + BoolItem1 Item[bool] + BoolItem2 Item[bool] + StringSlice List[string] + IntSlice List[int] + AddrItem Item[netip.Addr] + StringStringMap Map[string, string] + IntStringMap Map[int, string] + AddrIntMap Map[netip.Addr, int] + Bundle1 Item[*TestBundle] + Bundle2 Item[*TestBundle] + Generic Item[*TestGenericStruct[int]] + BundleList StructList[*TestBundle] + StringBundleMap StructMap[string, *TestBundle] + IntBundleMap StructMap[int, *TestBundle] + AddrBundleMap StructMap[netip.Addr, *TestBundle] + Group TestPrefsGroup +}{}) + +// Clone makes a deep copy of TestBundle. +// The result aliases no memory with the original. +func (src *TestBundle) Clone() *TestBundle { + if src == nil { + return nil + } + dst := new(TestBundle) + *dst = *src + if dst.Nested != nil { + dst.Nested = ptr.To(*src.Nested) + } + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestBundleCloneNeedsRegeneration = TestBundle(struct { + Name string + Nested *TestValueStruct +}{}) + +// Clone makes a deep copy of TestValueStruct. +// The result aliases no memory with the original. +func (src *TestValueStruct) Clone() *TestValueStruct { + if src == nil { + return nil + } + dst := new(TestValueStruct) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestValueStructCloneNeedsRegeneration = TestValueStruct(struct { + Value int +}{}) + +// Clone makes a deep copy of TestGenericStruct. +// The result aliases no memory with the original. +func (src *TestGenericStruct[T]) Clone() *TestGenericStruct[T] { + if src == nil { + return nil + } + dst := new(TestGenericStruct[T]) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +func _TestGenericStructCloneNeedsRegeneration[T ImmutableType](TestGenericStruct[T]) { + _TestGenericStructCloneNeedsRegeneration(struct { + Value T + }{}) +} + +// Clone makes a deep copy of TestPrefsGroup. +// The result aliases no memory with the original. +func (src *TestPrefsGroup) Clone() *TestPrefsGroup { + if src == nil { + return nil + } + dst := new(TestPrefsGroup) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestPrefsGroupCloneNeedsRegeneration = TestPrefsGroup(struct { + FloatItem Item[float64] + TestStringItem Item[TestStringType] +}{}) diff --git a/types/prefs/prefs_example/prefs_example_clone.go b/types/prefs/prefs_example/prefs_example_clone.go new file mode 100644 index 000000000..5c707b463 --- /dev/null +++ b/types/prefs/prefs_example/prefs_example_clone.go @@ -0,0 +1,99 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. + +package prefs_example + +import ( + "net/netip" + + "tailscale.com/drive" + "tailscale.com/tailcfg" + "tailscale.com/types/opt" + "tailscale.com/types/persist" + "tailscale.com/types/prefs" + "tailscale.com/types/preftype" +) + +// Clone makes a deep copy of Prefs. +// The result aliases no memory with the original. +func (src *Prefs) Clone() *Prefs { + if src == nil { + return nil + } + dst := new(Prefs) + *dst = *src + dst.AdvertiseTags = *src.AdvertiseTags.Clone() + dst.AdvertiseRoutes = *src.AdvertiseRoutes.Clone() + dst.DriveShares = *src.DriveShares.Clone() + dst.Persist = src.Persist.Clone() + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _PrefsCloneNeedsRegeneration = Prefs(struct { + ControlURL prefs.Item[string] + RouteAll prefs.Item[bool] + ExitNodeID prefs.Item[tailcfg.StableNodeID] + ExitNodeIP prefs.Item[netip.Addr] + ExitNodePrior tailcfg.StableNodeID + ExitNodeAllowLANAccess prefs.Item[bool] + CorpDNS prefs.Item[bool] + RunSSH prefs.Item[bool] + RunWebClient prefs.Item[bool] + WantRunning prefs.Item[bool] + LoggedOut prefs.Item[bool] + ShieldsUp prefs.Item[bool] + AdvertiseTags prefs.List[string] + Hostname prefs.Item[string] + NotepadURLs prefs.Item[bool] + ForceDaemon prefs.Item[bool] + Egg prefs.Item[bool] + AdvertiseRoutes prefs.List[netip.Prefix] + NoSNAT prefs.Item[bool] + NoStatefulFiltering prefs.Item[opt.Bool] + NetfilterMode prefs.Item[preftype.NetfilterMode] + OperatorUser prefs.Item[string] + ProfileName prefs.Item[string] + AutoUpdate AutoUpdatePrefs + AppConnector AppConnectorPrefs + PostureChecking prefs.Item[bool] + NetfilterKind prefs.Item[string] + DriveShares prefs.StructList[*drive.Share] + AllowSingleHosts prefs.Item[marshalAsTrueInJSON] + Persist *persist.Persist +}{}) + +// Clone makes a deep copy of AutoUpdatePrefs. +// The result aliases no memory with the original. +func (src *AutoUpdatePrefs) Clone() *AutoUpdatePrefs { + if src == nil { + return nil + } + dst := new(AutoUpdatePrefs) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _AutoUpdatePrefsCloneNeedsRegeneration = AutoUpdatePrefs(struct { + Check prefs.Item[bool] + Apply prefs.Item[opt.Bool] +}{}) + +// Clone makes a deep copy of AppConnectorPrefs. +// The result aliases no memory with the original. +func (src *AppConnectorPrefs) Clone() *AppConnectorPrefs { + if src == nil { + return nil + } + dst := new(AppConnectorPrefs) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _AppConnectorPrefsCloneNeedsRegeneration = AppConnectorPrefs(struct { + Advertise prefs.Item[bool] +}{}) diff --git a/types/prefs/prefs_example/prefs_example_view.go b/types/prefs/prefs_example/prefs_example_view.go new file mode 100644 index 000000000..0256bd7e6 --- /dev/null +++ b/types/prefs/prefs_example/prefs_example_view.go @@ -0,0 +1,239 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale/cmd/viewer; DO NOT EDIT. + +package prefs_example + +import ( + "encoding/json" + "errors" + "net/netip" + + "tailscale.com/drive" + "tailscale.com/tailcfg" + "tailscale.com/types/opt" + "tailscale.com/types/persist" + "tailscale.com/types/prefs" + "tailscale.com/types/preftype" +) + +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Prefs,AutoUpdatePrefs,AppConnectorPrefs + +// View returns a readonly view of Prefs. +func (p *Prefs) View() PrefsView { + return PrefsView{ж: p} +} + +// PrefsView provides a read-only view over Prefs. +// +// Its methods should only be called if `Valid()` returns true. +type PrefsView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *Prefs +} + +// Valid reports whether underlying value is non-nil. +func (v PrefsView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v PrefsView) AsStruct() *Prefs { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v PrefsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *PrefsView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x Prefs + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v PrefsView) ControlURL() prefs.Item[string] { return v.ж.ControlURL } +func (v PrefsView) RouteAll() prefs.Item[bool] { return v.ж.RouteAll } +func (v PrefsView) ExitNodeID() prefs.Item[tailcfg.StableNodeID] { return v.ж.ExitNodeID } +func (v PrefsView) ExitNodeIP() prefs.Item[netip.Addr] { return v.ж.ExitNodeIP } +func (v PrefsView) ExitNodePrior() tailcfg.StableNodeID { return v.ж.ExitNodePrior } +func (v PrefsView) ExitNodeAllowLANAccess() prefs.Item[bool] { return v.ж.ExitNodeAllowLANAccess } +func (v PrefsView) CorpDNS() prefs.Item[bool] { return v.ж.CorpDNS } +func (v PrefsView) RunSSH() prefs.Item[bool] { return v.ж.RunSSH } +func (v PrefsView) RunWebClient() prefs.Item[bool] { return v.ж.RunWebClient } +func (v PrefsView) WantRunning() prefs.Item[bool] { return v.ж.WantRunning } +func (v PrefsView) LoggedOut() prefs.Item[bool] { return v.ж.LoggedOut } +func (v PrefsView) ShieldsUp() prefs.Item[bool] { return v.ж.ShieldsUp } +func (v PrefsView) AdvertiseTags() prefs.ListView[string] { return v.ж.AdvertiseTags.View() } +func (v PrefsView) Hostname() prefs.Item[string] { return v.ж.Hostname } +func (v PrefsView) NotepadURLs() prefs.Item[bool] { return v.ж.NotepadURLs } +func (v PrefsView) ForceDaemon() prefs.Item[bool] { return v.ж.ForceDaemon } +func (v PrefsView) Egg() prefs.Item[bool] { return v.ж.Egg } +func (v PrefsView) AdvertiseRoutes() prefs.ListView[netip.Prefix] { return v.ж.AdvertiseRoutes.View() } +func (v PrefsView) NoSNAT() prefs.Item[bool] { return v.ж.NoSNAT } +func (v PrefsView) NoStatefulFiltering() prefs.Item[opt.Bool] { return v.ж.NoStatefulFiltering } +func (v PrefsView) NetfilterMode() prefs.Item[preftype.NetfilterMode] { return v.ж.NetfilterMode } +func (v PrefsView) OperatorUser() prefs.Item[string] { return v.ж.OperatorUser } +func (v PrefsView) ProfileName() prefs.Item[string] { return v.ж.ProfileName } +func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate } +func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector } +func (v PrefsView) PostureChecking() prefs.Item[bool] { return v.ж.PostureChecking } +func (v PrefsView) NetfilterKind() prefs.Item[string] { return v.ж.NetfilterKind } +func (v PrefsView) DriveShares() prefs.StructListView[*drive.Share, drive.ShareView] { + return prefs.StructListViewOf(&v.ж.DriveShares) +} +func (v PrefsView) AllowSingleHosts() prefs.Item[marshalAsTrueInJSON] { return v.ж.AllowSingleHosts } +func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _PrefsViewNeedsRegeneration = Prefs(struct { + ControlURL prefs.Item[string] + RouteAll prefs.Item[bool] + ExitNodeID prefs.Item[tailcfg.StableNodeID] + ExitNodeIP prefs.Item[netip.Addr] + ExitNodePrior tailcfg.StableNodeID + ExitNodeAllowLANAccess prefs.Item[bool] + CorpDNS prefs.Item[bool] + RunSSH prefs.Item[bool] + RunWebClient prefs.Item[bool] + WantRunning prefs.Item[bool] + LoggedOut prefs.Item[bool] + ShieldsUp prefs.Item[bool] + AdvertiseTags prefs.List[string] + Hostname prefs.Item[string] + NotepadURLs prefs.Item[bool] + ForceDaemon prefs.Item[bool] + Egg prefs.Item[bool] + AdvertiseRoutes prefs.List[netip.Prefix] + NoSNAT prefs.Item[bool] + NoStatefulFiltering prefs.Item[opt.Bool] + NetfilterMode prefs.Item[preftype.NetfilterMode] + OperatorUser prefs.Item[string] + ProfileName prefs.Item[string] + AutoUpdate AutoUpdatePrefs + AppConnector AppConnectorPrefs + PostureChecking prefs.Item[bool] + NetfilterKind prefs.Item[string] + DriveShares prefs.StructList[*drive.Share] + AllowSingleHosts prefs.Item[marshalAsTrueInJSON] + Persist *persist.Persist +}{}) + +// View returns a readonly view of AutoUpdatePrefs. +func (p *AutoUpdatePrefs) View() AutoUpdatePrefsView { + return AutoUpdatePrefsView{ж: p} +} + +// AutoUpdatePrefsView provides a read-only view over AutoUpdatePrefs. +// +// Its methods should only be called if `Valid()` returns true. +type AutoUpdatePrefsView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *AutoUpdatePrefs +} + +// Valid reports whether underlying value is non-nil. +func (v AutoUpdatePrefsView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v AutoUpdatePrefsView) AsStruct() *AutoUpdatePrefs { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v AutoUpdatePrefsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *AutoUpdatePrefsView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x AutoUpdatePrefs + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v AutoUpdatePrefsView) Check() prefs.Item[bool] { return v.ж.Check } +func (v AutoUpdatePrefsView) Apply() prefs.Item[opt.Bool] { return v.ж.Apply } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _AutoUpdatePrefsViewNeedsRegeneration = AutoUpdatePrefs(struct { + Check prefs.Item[bool] + Apply prefs.Item[opt.Bool] +}{}) + +// View returns a readonly view of AppConnectorPrefs. +func (p *AppConnectorPrefs) View() AppConnectorPrefsView { + return AppConnectorPrefsView{ж: p} +} + +// AppConnectorPrefsView provides a read-only view over AppConnectorPrefs. +// +// Its methods should only be called if `Valid()` returns true. +type AppConnectorPrefsView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *AppConnectorPrefs +} + +// Valid reports whether underlying value is non-nil. +func (v AppConnectorPrefsView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v AppConnectorPrefsView) AsStruct() *AppConnectorPrefs { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v AppConnectorPrefsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *AppConnectorPrefsView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x AppConnectorPrefs + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v AppConnectorPrefsView) Advertise() prefs.Item[bool] { return v.ж.Advertise } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _AppConnectorPrefsViewNeedsRegeneration = AppConnectorPrefs(struct { + Advertise prefs.Item[bool] +}{}) diff --git a/types/prefs/prefs_example/prefs_test.go b/types/prefs/prefs_example/prefs_test.go new file mode 100644 index 000000000..aefbae9f2 --- /dev/null +++ b/types/prefs/prefs_example/prefs_test.go @@ -0,0 +1,140 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs_example + +import ( + "fmt" + "net/netip" + + "tailscale.com/ipn" + "tailscale.com/types/prefs" +) + +func ExamplePrefs_AdvertiseRoutes_setValue() { + p := &Prefs{} + + // Initially, preferences are not configured. + fmt.Println("IsSet:", p.AdvertiseRoutes.IsSet()) // prints false + // And the Value method returns the default (or zero) value. + fmt.Println("Initial:", p.AdvertiseRoutes.Value()) // prints [] + + // Preferences can be configured with user-provided values using the + // SetValue method. It may fail if the preference is managed via syspolicy + // or is otherwise read-only. + routes := []netip.Prefix{netip.MustParsePrefix("192.168.1.1/24")} + if err := p.AdvertiseRoutes.SetValue(routes); err != nil { + // This block is never executed in the example because the + // AdvertiseRoutes preference is neither managed nor read-only. + fmt.Println("SetValue:", err) + } + fmt.Println("IsSet:", p.AdvertiseRoutes.IsSet()) // prints true + fmt.Println("Value:", p.AdvertiseRoutes.Value()) // prints 192.168.1.1/24 + + // Preference values are copied on use; you cannot not modify them after they are set. + routes[0] = netip.MustParsePrefix("10.10.10.0/24") // this has no effect + fmt.Println("Unchanged:", p.AdvertiseRoutes.Value()) // still prints 192.168.1.1/24 + // If necessary, the value can be changed by calling the SetValue method again. + p.AdvertiseRoutes.SetValue(routes) + fmt.Println("Changed:", p.AdvertiseRoutes.Value()) // prints 10.10.10.0/24 + + // The following code is fine when defining default or baseline prefs, or + // in tests. However, assigning to a preference field directly overwrites + // syspolicy-managed values and metadata, so it should generally be avoided + // when working with the actual profile or device preferences. + // It is caller's responsibility to use the mutable Prefs struct correctly. + defaults := &Prefs{WantRunning: prefs.ItemOf(true)} + defaults.CorpDNS = prefs.Item[bool]{} + defaults.ExitNodeAllowLANAccess = prefs.ItemOf(true) + _, _, _ = defaults.WantRunning, defaults.CorpDNS, defaults.ExitNodeAllowLANAccess + + // In most contexts, preferences should only be read and never mutated. + // To make it easier to enforce this guarantee, a view type generated with + // [tailscale.com/cmd/viewer] can be used instead of the mutable Prefs struct. + // Preferences accessed via a view have the same set of non-mutating + // methods as the underlying preferences but do not expose [prefs.Item.SetValue] or + // other methods that modify the preference's value or state. + v := p.View() + // Additionally, non-mutating methods like [prefs.ItemView.Value] and [prefs.ItemView.ValueOk] + // return read-only views of the underlying values instead of the actual potentially mutable values. + // For example, on the next line Value() returns a views.Slice[netip.Prefix], not a []netip.Prefix. + _ = v.AdvertiseRoutes().Value() + fmt.Println("Via View:", v.AdvertiseRoutes().Value().At(0)) // prints 10.10.10.0/24 + fmt.Println("IsSet:", v.AdvertiseRoutes().IsSet()) // prints true + fmt.Println("IsManaged:", v.AdvertiseRoutes().IsManaged()) // prints false + fmt.Println("IsReadOnly:", v.AdvertiseRoutes().IsReadOnly()) // prints false + + // Output: + // IsSet: false + // Initial: [] + // IsSet: true + // Value: [192.168.1.1/24] + // Unchanged: [192.168.1.1/24] + // Changed: [10.10.10.0/24] + // Via View: 10.10.10.0/24 + // IsSet: true + // IsManaged: false + // IsReadOnly: false +} + +func ExamplePrefs_ControlURL_setDefaultValue() { + p := &Prefs{} + v := p.View() + + // We can set default values for preferences when their default values + // should differ from the zero values of the corresponding Go types. + // + // Note that in this example, we configure preferences via a mutable + // [Prefs] struct but fetch values via a read-only [PrefsView]. + // Typically, we set and get preference values in different parts + // of the codebase. + p.ControlURL.SetDefaultValue(ipn.DefaultControlURL) + // The default value is used if the preference is not configured... + fmt.Println("Default:", v.ControlURL().Value()) + p.ControlURL.SetValue("https://control.example.com") + fmt.Println("User Set:", v.ControlURL().Value()) + // ...including when it has been reset. + p.ControlURL.ClearValue() + fmt.Println("Reset to Default:", v.ControlURL().Value()) + + // Output: + // Default: https://controlplane.tailscale.com + // User Set: https://control.example.com + // Reset to Default: https://controlplane.tailscale.com +} + +func ExamplePrefs_ExitNodeID_setManagedValue() { + p := &Prefs{} + v := p.View() + + // We can mark preferences as being managed via syspolicy (e.g., via GP/MDM) + // by setting its managed value. + // + // Note that in this example, we enforce syspolicy-managed values + // via a mutable [Prefs] struct but fetch values via a read-only [PrefsView]. + // This is typically spread throughout the codebase. + p.ExitNodeID.SetManagedValue("ManagedExitNode") + // Marking a preference as managed prevents it from being changed by the user. + if err := p.ExitNodeID.SetValue("CustomExitNode"); err != nil { + fmt.Println("SetValue:", err) // reports an error + } + fmt.Println("Exit Node:", v.ExitNodeID().Value()) // prints ManagedExitNode + + // Clients can hide or disable preferences that are managed or read-only. + fmt.Println("IsManaged:", v.ExitNodeID().IsManaged()) // prints true + fmt.Println("IsReadOnly:", v.ExitNodeID().IsReadOnly()) // prints true; managed preferences are always read-only. + + // ClearManaged is called when the preference is no longer managed, + // allowing the user to change it. + p.ExitNodeID.ClearManaged() + fmt.Println("IsManaged:", v.ExitNodeID().IsManaged()) // prints false + fmt.Println("IsReadOnly:", v.ExitNodeID().IsReadOnly()) // prints false + + // Output: + // SetValue: cannot modify a managed preference + // Exit Node: ManagedExitNode + // IsManaged: true + // IsReadOnly: true + // IsManaged: false + // IsReadOnly: false +} diff --git a/types/prefs/prefs_example/prefs_types.go b/types/prefs/prefs_example/prefs_types.go new file mode 100644 index 000000000..49f0d8c3c --- /dev/null +++ b/types/prefs/prefs_example/prefs_types.go @@ -0,0 +1,166 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package prefs_example contains a [Prefs] type, which is like [tailscale.com/ipn.Prefs], +// but uses the [prefs] package to enhance individual preferences with state and metadata. +// +// It also includes testable examples utilizing the [Prefs] type. +// We made it a separate package to avoid circular dependencies +// and due to limitations in [tailscale.com/cmd/viewer] when +// generating code for test packages. +package prefs_example + +import ( + "net/netip" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "tailscale.com/drive" + "tailscale.com/tailcfg" + "tailscale.com/types/opt" + "tailscale.com/types/persist" + "tailscale.com/types/prefs" + "tailscale.com/types/preftype" +) + +//go:generate go run tailscale.com/cmd/viewer --type=Prefs,AutoUpdatePrefs,AppConnectorPrefs + +// Prefs is like [tailscale.com/ipn.Prefs], but with individual preferences wrapped in +// [prefs.Item], [prefs.List], and [prefs.StructList] to include preference +// state and metadata. Related preferences can be grouped together in a nested +// struct (e.g., [AutoUpdatePrefs] or [AppConnectorPrefs]), whereas each +// individual preference that can be configured by a user or managed via +// syspolicy is wrapped. +// +// Non-preference fields, such as ExitNodePrior and Persist, can be included as-is. +// +// Just like [tailscale.com/ipn.Prefs], [Prefs] is a mutable struct. It should +// only be used in well-defined contexts where mutability is expected and desired, +// such as when the LocalBackend receives a request from the GUI/CLI to change a +// preference, when a preference is managed via syspolicy and needs to be +// configured with an admin-provided value, or when the internal state (e.g., +// [persist.Persist]) has changed and needs to be preserved. +// In other contexts, a [PrefsView] should be used to provide a read-only view +// of the preferences. +// +// It is recommended to use [jsonv2] for [Prefs] marshaling and unmarshalling to +// improve performance and enable the omission of unconfigured preferences with +// the `omitzero` JSON tag option. This option is not supported by the +// [encoding/json] package as of 2024-08-21; see golang/go#45669. +// It is recommended that a prefs type implements both +// [jsonv2.MarshalerV2]/[jsonv2.UnmarshalerV2] and [json.Marshaler]/[json.Unmarshaler] +// to ensure consistent and more performant marshaling, regardless of the JSON package +// used at the call sites; the standard marshalers can be implemented via [jsonv2]. +// See [Prefs.MarshalJSONV2], [Prefs.UnmarshalJSONV2], [Prefs.MarshalJSON], +// and [Prefs.UnmarshalJSON] for an example implementation. +type Prefs struct { + ControlURL prefs.Item[string] `json:",omitzero"` + RouteAll prefs.Item[bool] `json:",omitzero"` + ExitNodeID prefs.Item[tailcfg.StableNodeID] `json:",omitzero"` + ExitNodeIP prefs.Item[netip.Addr] `json:",omitzero"` + + // ExitNodePrior is an internal state rather than a preference. + // It can be kept in the Prefs structure but should not be wrapped + // and is ignored by the [prefs] package. + ExitNodePrior tailcfg.StableNodeID + + ExitNodeAllowLANAccess prefs.Item[bool] `json:",omitzero"` + CorpDNS prefs.Item[bool] `json:",omitzero"` + RunSSH prefs.Item[bool] `json:",omitzero"` + RunWebClient prefs.Item[bool] `json:",omitzero"` + WantRunning prefs.Item[bool] `json:",omitzero"` + LoggedOut prefs.Item[bool] `json:",omitzero"` + ShieldsUp prefs.Item[bool] `json:",omitzero"` + // AdvertiseTags is a preference whose value is a slice of strings. + // The value is atomic, and individual items in the slice should + // not be modified after the preference is set. + // Since the item type (string) is immutable, we can use [prefs.List]. + AdvertiseTags prefs.List[string] `json:",omitzero"` + Hostname prefs.Item[string] `json:",omitzero"` + NotepadURLs prefs.Item[bool] `json:",omitzero"` + ForceDaemon prefs.Item[bool] `json:",omitzero"` + Egg prefs.Item[bool] `json:",omitzero"` + // AdvertiseRoutes is a preference whose value is a slice of netip.Prefix. + // The value is atomic, and individual items in the slice should + // not be modified after the preference is set. + // Since the item type (netip.Prefix) is immutable, we can use [prefs.List]. + AdvertiseRoutes prefs.List[netip.Prefix] `json:",omitzero"` + NoSNAT prefs.Item[bool] `json:",omitzero"` + NoStatefulFiltering prefs.Item[opt.Bool] `json:",omitzero"` + NetfilterMode prefs.Item[preftype.NetfilterMode] `json:",omitzero"` + OperatorUser prefs.Item[string] `json:",omitzero"` + ProfileName prefs.Item[string] `json:",omitzero"` + + // AutoUpdate contains auto-update preferences. + // Each preference in the group can be configured and managed individually. + AutoUpdate AutoUpdatePrefs `json:",omitzero"` + + // AppConnector contains app connector-related preferences. + // Each preference in the group can be configured and managed individually. + AppConnector AppConnectorPrefs `json:",omitzero"` + + PostureChecking prefs.Item[bool] `json:",omitzero"` + NetfilterKind prefs.Item[string] `json:",omitzero"` + // DriveShares is a preference whose value is a slice of *[drive.Share]. + // The value is atomic, and individual items in the slice should + // not be modified after the preference is set. + // Since the item type (*drive.Share) is mutable and implements [views.ViewCloner], + // we need to use [prefs.StructList] instead of [prefs.List]. + DriveShares prefs.StructList[*drive.Share] `json:",omitzero"` + AllowSingleHosts prefs.Item[marshalAsTrueInJSON] `json:",omitzero"` + + // Persist is an internal state rather than a preference. + // It can be kept in the Prefs structure but should not be wrapped + // and is ignored by the [prefs] package. + Persist *persist.Persist `json:"Config"` +} + +// AutoUpdatePrefs is like [ipn.AutoUpdatePrefs], but it wraps individual preferences with [prefs.Item]. +// It groups related preferences together while allowing each to be configured individually. +type AutoUpdatePrefs struct { + Check prefs.Item[bool] `json:",omitzero"` + Apply prefs.Item[opt.Bool] `json:",omitzero"` +} + +// AppConnectorPrefs is like [ipn.AppConnectorPrefs], but it wraps individual preferences with [prefs.Item]. +// It groups related preferences together while allowing each to be configured individually. +type AppConnectorPrefs struct { + Advertise prefs.Item[bool] `json:",omitzero"` +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +// It is implemented as a performance improvement and to enable omission of +// unconfigured preferences from the JSON output. See the [Prefs] doc for details. +func (p Prefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + // The prefs type shadows the Prefs's method set, + // causing [jsonv2] to use the default marshaler and avoiding + // infinite recursion. + type prefs Prefs + return jsonv2.MarshalEncode(out, (*prefs)(&p), opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (p *Prefs) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + // The prefs type shadows the Prefs's method set, + // causing [jsonv2] to use the default unmarshaler and avoiding + // infinite recursion. + type prefs Prefs + return jsonv2.UnmarshalDecode(in, (*prefs)(p), opts) +} + +// MarshalJSON implements [json.Marshaler]. +func (p Prefs) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(p) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (p *Prefs) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2 +} + +type marshalAsTrueInJSON struct{} + +var trueJSON = []byte("true") + +func (marshalAsTrueInJSON) MarshalJSON() ([]byte, error) { return trueJSON, nil } +func (*marshalAsTrueInJSON) UnmarshalJSON([]byte) error { return nil } diff --git a/types/prefs/prefs_test.go b/types/prefs/prefs_test.go new file mode 100644 index 000000000..ea4729366 --- /dev/null +++ b/types/prefs/prefs_test.go @@ -0,0 +1,670 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "bytes" + "encoding/json" + "errors" + "net/netip" + "reflect" + "testing" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "github.com/google/go-cmp/cmp" + "tailscale.com/types/views" +) + +//go:generate go run tailscale.com/cmd/viewer --tags=test --type=TestPrefs,TestBundle,TestValueStruct,TestGenericStruct,TestPrefsGroup + +type TestPrefs struct { + Int32Item Item[int32] `json:",omitzero"` + UInt64Item Item[uint64] `json:",omitzero"` + StringItem1 Item[string] `json:",omitzero"` + StringItem2 Item[string] `json:",omitzero"` + BoolItem1 Item[bool] `json:",omitzero"` + BoolItem2 Item[bool] `json:",omitzero"` + StringSlice List[string] `json:",omitzero"` + IntSlice List[int] `json:",omitzero"` + + AddrItem Item[netip.Addr] `json:",omitzero"` + + StringStringMap Map[string, string] `json:",omitzero"` + IntStringMap Map[int, string] `json:",omitzero"` + AddrIntMap Map[netip.Addr, int] `json:",omitzero"` + + // Bundles are complex preferences that usually consist of + // multiple parameters that must be configured atomically. + Bundle1 Item[*TestBundle] `json:",omitzero"` + Bundle2 Item[*TestBundle] `json:",omitzero"` + Generic Item[*TestGenericStruct[int]] `json:",omitzero"` + + BundleList StructList[*TestBundle] `json:",omitzero"` + + StringBundleMap StructMap[string, *TestBundle] `json:",omitzero"` + IntBundleMap StructMap[int, *TestBundle] `json:",omitzero"` + AddrBundleMap StructMap[netip.Addr, *TestBundle] `json:",omitzero"` + + // Group is a nested struct that contains one or more preferences. + // Each preference in a group can be configured individually. + // Preference groups should be included directly rather than by pointers. + Group TestPrefsGroup `json:",omitzero"` +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (p TestPrefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + // The testPrefs type shadows the TestPrefs's method set, + // causing jsonv2 to use the default marshaler and avoiding + // infinite recursion. + type testPrefs TestPrefs + return jsonv2.MarshalEncode(out, (*testPrefs)(&p), opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (p *TestPrefs) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + // The testPrefs type shadows the TestPrefs's method set, + // causing jsonv2 to use the default unmarshaler and avoiding + // infinite recursion. + type testPrefs TestPrefs + return jsonv2.UnmarshalDecode(in, (*testPrefs)(p), opts) +} + +// MarshalJSON implements [json.Marshaler]. +func (p TestPrefs) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(p) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (p *TestPrefs) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2 +} + +// TestBundle is an example structure type that, +// despite containing multiple values, represents +// a single configurable preference item. +type TestBundle struct { + Name string `json:",omitzero"` + Nested *TestValueStruct `json:",omitzero"` +} + +func (b *TestBundle) Equal(b2 *TestBundle) bool { + if b == b2 { + return true + } + if b == nil || b2 == nil { + return false + } + return b.Name == b2.Name && b.Nested.Equal(b2.Nested) +} + +// TestPrefsGroup contains logically grouped preference items. +// Each preference item in a group can be configured individually. +type TestPrefsGroup struct { + FloatItem Item[float64] `json:",omitzero"` + + TestStringItem Item[TestStringType] `json:",omitzero"` +} + +type TestValueStruct struct { + Value int +} + +func (s *TestValueStruct) Equal(s2 *TestValueStruct) bool { + if s == s2 { + return true + } + if s == nil || s2 == nil { + return false + } + return *s == *s2 +} + +type TestGenericStruct[T ImmutableType] struct { + Value T +} + +func (s *TestGenericStruct[T]) Equal(s2 *TestGenericStruct[T]) bool { + if s == s2 { + return true + } + if s == nil || s2 == nil { + return false + } + return *s == *s2 +} + +type TestStringType string + +func TestMarshalUnmarshal(t *testing.T) { + tests := []struct { + name string + prefs *TestPrefs + indent bool + want string + }{ + { + name: "string", + prefs: &TestPrefs{StringItem1: ItemOf("Value1")}, + want: `{"StringItem1": {"Value": "Value1"}}`, + }, + { + name: "empty-string", + prefs: &TestPrefs{StringItem1: ItemOf("")}, + want: `{"StringItem1": {"Value": ""}}`, + }, + { + name: "managed-string", + prefs: &TestPrefs{StringItem1: ItemOf("Value1", Managed)}, + want: `{"StringItem1": {"Value": "Value1", "Managed": true}}`, + }, + { + name: "readonly-item", + prefs: &TestPrefs{StringItem1: ItemWithOpts[string](ReadOnly)}, + want: `{"StringItem1": {"ReadOnly": true}}`, + }, + { + name: "readonly-item-with-value", + prefs: &TestPrefs{StringItem1: ItemOf("RO", ReadOnly)}, + want: `{"StringItem1": {"Value": "RO", "ReadOnly": true}}`, + }, + { + name: "int32", + prefs: &TestPrefs{Int32Item: ItemOf[int32](101)}, + want: `{"Int32Item": {"Value": 101}}`, + }, + { + name: "uint64", + prefs: &TestPrefs{UInt64Item: ItemOf[uint64](42)}, + want: `{"UInt64Item": {"Value": 42}}`, + }, + { + name: "bool-true", + prefs: &TestPrefs{BoolItem1: ItemOf(true)}, + want: `{"BoolItem1": {"Value": true}}`, + }, + { + name: "bool-false", + prefs: &TestPrefs{BoolItem1: ItemOf(false)}, + want: `{"BoolItem1": {"Value": false}}`, + }, + { + name: "empty-slice", + prefs: &TestPrefs{StringSlice: ListOf([]string{})}, + want: `{"StringSlice": {"Value": []}}`, + }, + { + name: "string-slice", + prefs: &TestPrefs{StringSlice: ListOf([]string{"1", "2", "3"})}, + want: `{"StringSlice": {"Value": ["1", "2", "3"]}}`, + }, + { + name: "int-slice", + prefs: &TestPrefs{IntSlice: ListOf([]int{4, 8, 15, 16, 23})}, + want: `{"IntSlice": {"Value": [4, 8, 15, 16, 23]}}`, + }, + { + name: "managed-int-slice", + prefs: &TestPrefs{IntSlice: ListOf([]int{4, 8, 15, 16, 23}, Managed)}, + want: `{"IntSlice": {"Value": [4, 8, 15, 16, 23], "Managed": true}}`, + }, + { + name: "netip-addr", + prefs: &TestPrefs{AddrItem: ItemOf(netip.MustParseAddr("127.0.0.1"))}, + want: `{"AddrItem": {"Value": "127.0.0.1"}}`, + }, + { + name: "string-string-map", + prefs: &TestPrefs{StringStringMap: MapOf(map[string]string{"K1": "V1"})}, + want: `{"StringStringMap": {"Value": {"K1": "V1"}}}`, + }, + { + name: "int-string-map", + prefs: &TestPrefs{IntStringMap: MapOf(map[int]string{42: "V1"})}, + want: `{"IntStringMap": {"Value": {"42": "V1"}}}`, + }, + { + name: "addr-int-map", + prefs: &TestPrefs{AddrIntMap: MapOf(map[netip.Addr]int{netip.MustParseAddr("127.0.0.1"): 42})}, + want: `{"AddrIntMap": {"Value": {"127.0.0.1": 42}}}`, + }, + { + name: "bundle-list", + prefs: &TestPrefs{BundleList: StructListOf([]*TestBundle{{Name: "Bundle1"}, {Name: "Bundle2"}})}, + want: `{"BundleList": {"Value": [{"Name": "Bundle1"},{"Name": "Bundle2"}]}}`, + }, + { + name: "string-bundle-map", + prefs: &TestPrefs{StringBundleMap: StructMapOf(map[string]*TestBundle{ + "K1": {Name: "Bundle1"}, + "K2": {Name: "Bundle2"}, + })}, + want: `{"StringBundleMap": {"Value": {"K1": {"Name": "Bundle1"}, "K2": {"Name": "Bundle2"}}}}`, + }, + { + name: "int-bundle-map", + prefs: &TestPrefs{IntBundleMap: StructMapOf(map[int]*TestBundle{42: {Name: "Bundle1"}})}, + want: `{"IntBundleMap": {"Value": {"42": {"Name": "Bundle1"}}}}`, + }, + { + name: "addr-bundle-map", + prefs: &TestPrefs{AddrBundleMap: StructMapOf(map[netip.Addr]*TestBundle{netip.MustParseAddr("127.0.0.1"): {Name: "Bundle1"}})}, + want: `{"AddrBundleMap": {"Value": {"127.0.0.1": {"Name": "Bundle1"}}}}`, + }, + { + name: "bundle", + prefs: &TestPrefs{Bundle1: ItemOf(&TestBundle{Name: "Bundle1"})}, + want: `{"Bundle1": {"Value": {"Name": "Bundle1"}}}`, + }, + { + name: "managed-bundle", + prefs: &TestPrefs{Bundle2: ItemOf(&TestBundle{Name: "Bundle2", Nested: &TestValueStruct{Value: 17}}, Managed)}, + want: `{"Bundle2": {"Value": {"Name": "Bundle2", "Nested": {"Value": 17}}, "Managed": true}}`, + }, + { + name: "subgroup", + prefs: &TestPrefs{Group: TestPrefsGroup{FloatItem: ItemOf(1.618), TestStringItem: ItemOf(TestStringType("Value"))}}, + want: `{"Group": {"FloatItem": {"Value": 1.618}, "TestStringItem": {"Value": "Value"}}}`, + }, + { + name: "various", + prefs: &TestPrefs{ + Int32Item: ItemOf[int32](101), + UInt64Item: ItemOf[uint64](42), + StringItem1: ItemOf("Value1"), + StringItem2: ItemWithOpts[string](ReadOnly), + BoolItem1: ItemOf(true), + BoolItem2: ItemOf(false, Managed), + StringSlice: ListOf([]string{"1", "2", "3"}), + IntSlice: ListOf([]int{4, 8, 15, 16, 23}, Managed), + AddrItem: ItemOf(netip.MustParseAddr("127.0.0.1")), + StringStringMap: MapOf(map[string]string{"K1": "V1"}), + IntStringMap: MapOf(map[int]string{42: "V1"}), + AddrIntMap: MapOf(map[netip.Addr]int{netip.MustParseAddr("127.0.0.1"): 42}), + BundleList: StructListOf([]*TestBundle{{Name: "Bundle1"}}), + StringBundleMap: StructMapOf(map[string]*TestBundle{"K1": {Name: "Bundle1"}}), + IntBundleMap: StructMapOf(map[int]*TestBundle{42: {Name: "Bundle1"}}), + AddrBundleMap: StructMapOf(map[netip.Addr]*TestBundle{netip.MustParseAddr("127.0.0.1"): {Name: "Bundle1"}}), + Bundle1: ItemOf(&TestBundle{Name: "Bundle1"}), + Bundle2: ItemOf(&TestBundle{Name: "Bundle2", Nested: &TestValueStruct{Value: 17}}, Managed), + Group: TestPrefsGroup{ + FloatItem: ItemOf(1.618), + TestStringItem: ItemOf(TestStringType("Value")), + }, + }, + want: `{ + "Int32Item": {"Value": 101}, + "UInt64Item": {"Value": 42}, + "StringItem1": {"Value": "Value1"}, + "StringItem2": {"ReadOnly": true}, + "BoolItem1": {"Value": true}, + "BoolItem2": {"Value": false, "Managed": true}, + "StringSlice": {"Value": ["1", "2", "3"]}, + "IntSlice": {"Value": [4, 8, 15, 16, 23], "Managed": true}, + "AddrItem": {"Value": "127.0.0.1"}, + "StringStringMap": {"Value": {"K1": "V1"}}, + "IntStringMap": {"Value": {"42": "V1"}}, + "AddrIntMap": {"Value": {"127.0.0.1": 42}}, + "BundleList": {"Value": [{"Name": "Bundle1"}]}, + "StringBundleMap": {"Value": {"K1": {"Name": "Bundle1"}}}, + "IntBundleMap": {"Value": {"42": {"Name": "Bundle1"}}}, + "AddrBundleMap": {"Value": {"127.0.0.1": {"Name": "Bundle1"}}}, + "Bundle1": {"Value": {"Name": "Bundle1"}}, + "Bundle2": {"Value": {"Name": "Bundle2", "Nested": {"Value": 17}}, "Managed": true}, + "Group": { + "FloatItem": {"Value": 1.618}, + "TestStringItem": {"Value": "Value"} + } + }`, + }, + } + + arshalers := []struct { + name string + marshal func(in any) (out []byte, err error) + unmarshal func(in []byte, out any) (err error) + }{ + { + name: "json", + marshal: json.Marshal, + unmarshal: json.Unmarshal, + }, + { + name: "jsonv2", + marshal: func(in any) (out []byte, err error) { return jsonv2.Marshal(in) }, + unmarshal: func(in []byte, out any) (err error) { return jsonv2.Unmarshal(in, out) }, + }, + } + + for _, a := range arshalers { + t.Run(a.name, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Run("marshal-directly", func(t *testing.T) { + gotJSON, err := a.marshal(tt.prefs) + if err != nil { + t.Fatalf("marshalling failed: %v", err) + } + + checkJSON(t, gotJSON, jsontext.Value(tt.want)) + + var gotPrefs TestPrefs + if err = a.unmarshal(gotJSON, &gotPrefs); err != nil { + t.Fatalf("unmarshalling failed: %v", err) + } + + if diff := cmp.Diff(tt.prefs, &gotPrefs); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("marshal-via-view", func(t *testing.T) { + gotJSON, err := a.marshal(tt.prefs.View()) + if err != nil { + t.Fatalf("marshalling failed: %v", err) + } + + checkJSON(t, gotJSON, jsontext.Value(tt.want)) + + var gotPrefs TestPrefsView + if err = a.unmarshal(gotJSON, &gotPrefs); err != nil { + t.Fatalf("unmarshalling failed: %v", err) + } + + if diff := cmp.Diff(tt.prefs, gotPrefs.AsStruct()); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + }) + } + }) + } +} + +func TestPreferenceStates(t *testing.T) { + const ( + zeroValue = 0 + defValue = 5 + userValue = 42 + mdmValue = 1001 + ) + i := ItemWithOpts[int]() + checkIsSet(t, &i, false) + checkIsManaged(t, &i, false) + checkIsReadOnly(t, &i, false) + checkValueOk(t, &i, zeroValue, false) + + i.SetDefaultValue(defValue) + checkValue(t, &i, defValue) + checkValueOk(t, &i, defValue, false) + + checkSetValue(t, &i, userValue) + checkValue(t, &i, userValue) + checkValueOk(t, &i, userValue, true) + + i2 := ItemOf(userValue) + checkIsSet(t, &i2, true) + checkValue(t, &i2, userValue) + checkValueOk(t, &i2, userValue, true) + checkEqual(t, i2, i, true) + + i2.SetManagedValue(mdmValue) + // Setting a managed value should set the value, mark the preference + // as managed and read-only, and prevent it from being modified with SetValue. + checkIsSet(t, &i2, true) + checkIsManaged(t, &i2, true) + checkIsReadOnly(t, &i2, true) + checkValue(t, &i2, mdmValue) + checkValueOk(t, &i2, mdmValue, true) + checkCanNotSetValue(t, &i2, userValue, ErrManaged) + checkValue(t, &i2, mdmValue) // the value must not be changed + checkCanNotClearValue(t, &i2, ErrManaged) + + i2.ClearManaged() + // Clearing the managed flag should change the IsManaged and IsReadOnly flags... + checkIsManaged(t, &i2, false) + checkIsReadOnly(t, &i2, false) + // ...but not the value. + checkValue(t, &i2, mdmValue) + + // We should be able to change the value after clearing the managed flag. + checkSetValue(t, &i2, userValue) + checkIsSet(t, &i2, true) + checkValue(t, &i2, userValue) + checkValueOk(t, &i2, userValue, true) + checkEqual(t, i2, i, true) + + i2.SetReadOnly(true) + checkIsReadOnly(t, &i2, true) + checkIsManaged(t, &i2, false) + checkCanNotSetValue(t, &i2, userValue, ErrReadOnly) + checkCanNotClearValue(t, &i2, ErrReadOnly) + + i2.SetReadOnly(false) + i2.SetDefaultValue(defValue) + checkClearValue(t, &i2) + checkIsSet(t, &i2, false) + checkValue(t, &i2, defValue) + checkValueOk(t, &i2, defValue, false) +} + +func TestItemView(t *testing.T) { + i := ItemOf(&TestBundle{Name: "B1"}) + + iv := ItemViewOf(&i) + checkIsSet(t, iv, true) + checkIsManaged(t, iv, false) + checkIsReadOnly(t, iv, false) + checkValue(t, iv, TestBundleView{i.Value()}) + checkValueOk(t, iv, TestBundleView{i.Value()}, true) + + i2 := *iv.AsStruct() + checkEqual(t, i, i2, true) + i2.SetValue(&TestBundle{Name: "B2"}) + + iv2 := ItemViewOf(&i2) + checkEqual(t, iv, iv2, false) +} + +func TestListView(t *testing.T) { + l := ListOf([]int{4, 8, 15, 16, 23, 42}, ReadOnly) + + lv := l.View() + checkIsSet(t, lv, true) + checkIsManaged(t, lv, false) + checkIsReadOnly(t, lv, true) + checkValue(t, lv, views.SliceOf(l.Value())) + checkValueOk(t, lv, views.SliceOf(l.Value()), true) + + l2 := *lv.AsStruct() + checkEqual(t, l, l2, true) +} + +func TestStructListView(t *testing.T) { + l := StructListOf([]*TestBundle{{Name: "E1"}, {Name: "E2"}}, ReadOnly) + + lv := StructListViewOf(&l) + checkIsSet(t, lv, true) + checkIsManaged(t, lv, false) + checkIsReadOnly(t, lv, true) + checkValue(t, lv, views.SliceOfViews(l.Value())) + checkValueOk(t, lv, views.SliceOfViews(l.Value()), true) + + l2 := *lv.AsStruct() + checkEqual(t, l, l2, true) +} + +func TestStructMapView(t *testing.T) { + m := StructMapOf(map[string]*TestBundle{ + "K1": {Name: "E1"}, + "K2": {Name: "E2"}, + }, ReadOnly) + + mv := StructMapViewOf(&m) + checkIsSet(t, mv, true) + checkIsManaged(t, mv, false) + checkIsReadOnly(t, mv, true) + checkValue(t, *mv.AsStruct(), m.Value()) + checkValueOk(t, *mv.AsStruct(), m.Value(), true) + + m2 := *mv.AsStruct() + checkEqual(t, m, m2, true) +} + +// check that the preference types implement the test [pref] interface. +var ( + _ pref[int] = (*Item[int])(nil) + _ pref[*TestBundle] = (*Item[*TestBundle])(nil) + _ pref[[]int] = (*List[int])(nil) + _ pref[[]*TestBundle] = (*StructList[*TestBundle])(nil) + _ pref[map[string]*TestBundle] = (*StructMap[string, *TestBundle])(nil) +) + +// pref is an interface used by [checkSetValue], [checkClearValue], and similar test +// functions that mutate preferences. It is implemented by all preference types, such +// as [Item], [List], [StructList], and [StructMap], and provides both read and write +// access to the preference's value and state. +type pref[T any] interface { + prefView[T] + SetValue(v T) error + ClearValue() error + SetDefaultValue(v T) + SetManagedValue(v T) + ClearManaged() + SetReadOnly(readonly bool) +} + +// check that the preference view types implement the test [prefView] interface. +var ( + _ prefView[int] = (*Item[int])(nil) + _ prefView[TestBundleView] = (*ItemView[*TestBundle, TestBundleView])(nil) + _ prefView[views.Slice[int]] = (*ListView[int])(nil) + _ prefView[views.SliceView[*TestBundle, TestBundleView]] = (*StructListView[*TestBundle, TestBundleView])(nil) + _ prefView[views.MapFn[string, *TestBundle, TestBundleView]] = (*StructMapView[string, *TestBundle, TestBundleView])(nil) +) + +// prefView is an interface used by [checkIsSet], [checkIsManaged], and similar non-mutating +// test functions. It is implemented by all preference types, such as [Item], [List], [StructList], +// and [StructMap], as well as their corresponding views, such as [ItemView], [ListView], [StructListView], +// and [StructMapView], and provides read-only access to the preference's value and state. +type prefView[T any] interface { + IsSet() bool + Value() T + ValueOk() (T, bool) + DefaultValue() T + IsManaged() bool + IsReadOnly() bool +} + +func checkIsSet[T any](tb testing.TB, p prefView[T], wantSet bool) { + tb.Helper() + if gotSet := p.IsSet(); gotSet != wantSet { + tb.Errorf("IsSet: got %v; want %v", gotSet, wantSet) + } +} + +func checkIsManaged[T any](tb testing.TB, p prefView[T], wantManaged bool) { + tb.Helper() + if gotManaged := p.IsManaged(); gotManaged != wantManaged { + tb.Errorf("IsManaged: got %v; want %v", gotManaged, wantManaged) + } +} + +func checkIsReadOnly[T any](tb testing.TB, p prefView[T], wantReadOnly bool) { + tb.Helper() + if gotReadOnly := p.IsReadOnly(); gotReadOnly != wantReadOnly { + tb.Errorf("IsReadOnly: got %v; want %v", gotReadOnly, wantReadOnly) + } +} + +func checkValue[T any](tb testing.TB, p prefView[T], wantValue T) { + tb.Helper() + if gotValue := p.Value(); !testComparerFor[T]()(gotValue, wantValue) { + tb.Errorf("Value: got %v; want %v", gotValue, wantValue) + } +} + +func checkValueOk[T any](tb testing.TB, p prefView[T], wantValue T, wantOk bool) { + tb.Helper() + gotValue, gotOk := p.ValueOk() + + if gotOk != wantOk || !testComparerFor[T]()(gotValue, wantValue) { + tb.Errorf("ValueOk: got (%v, %v); want (%v, %v)", gotValue, gotOk, wantValue, wantOk) + } +} + +func checkEqual[T equatable[T]](tb testing.TB, a, b T, wantEqual bool) { + tb.Helper() + if gotEqual := a.Equal(b); gotEqual != wantEqual { + tb.Errorf("Equal: got %v; want %v", gotEqual, wantEqual) + } +} + +func checkSetValue[T any](tb testing.TB, p pref[T], v T) { + tb.Helper() + if err := p.SetValue(v); err != nil { + tb.Fatalf("SetValue: gotErr %v, wantErr: nil", err) + } +} + +func checkCanNotSetValue[T any](tb testing.TB, p pref[T], v T, wantErr error) { + tb.Helper() + if err := p.SetValue(v); err == nil || !errors.Is(err, wantErr) { + tb.Fatalf("SetValue: gotErr %v, wantErr: %v", err, wantErr) + } +} + +func checkClearValue[T any](tb testing.TB, p pref[T]) { + tb.Helper() + if err := p.ClearValue(); err != nil { + tb.Fatalf("ClearValue: gotErr %v, wantErr: nil", err) + } +} + +func checkCanNotClearValue[T any](tb testing.TB, p pref[T], wantErr error) { + tb.Helper() + err := p.ClearValue() + if err == nil || !errors.Is(err, wantErr) { + tb.Fatalf("ClearValue: gotErr %v, wantErr: %v", err, wantErr) + } +} + +// testComparerFor is like [comparerFor], but uses [reflect.DeepEqual] +// unless T is [equatable]. +func testComparerFor[T any]() func(a, b T) bool { + return func(a, b T) bool { + switch a := any(a).(type) { + case equatable[T]: + return a.Equal(b) + default: + return reflect.DeepEqual(a, b) + } + } +} + +func checkJSON(tb testing.TB, got, want jsontext.Value) { + tb.Helper() + got = got.Clone() + want = want.Clone() + // Compare canonical forms. + if err := got.Canonicalize(); err != nil { + tb.Error(err) + } + if err := want.Canonicalize(); err != nil { + tb.Error(err) + } + if bytes.Equal(got, want) { + return + } + + gotMap := make(map[string]any) + if err := jsonv2.Unmarshal(got, &gotMap); err != nil { + tb.Fatal(err) + } + wantMap := make(map[string]any) + if err := jsonv2.Unmarshal(want, &wantMap); err != nil { + tb.Fatal(err) + } + tb.Errorf("mismatch (-want +got):\n%s", cmp.Diff(wantMap, gotMap)) +} diff --git a/types/prefs/prefs_view_test.go b/types/prefs/prefs_view_test.go new file mode 100644 index 000000000..d76eebb43 --- /dev/null +++ b/types/prefs/prefs_view_test.go @@ -0,0 +1,342 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale/cmd/viewer; DO NOT EDIT. + +package prefs + +import ( + "encoding/json" + "errors" + "net/netip" +) + +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TestPrefs,TestBundle,TestValueStruct,TestGenericStruct,TestPrefsGroup -tags=test + +// View returns a readonly view of TestPrefs. +func (p *TestPrefs) View() TestPrefsView { + return TestPrefsView{ж: p} +} + +// TestPrefsView provides a read-only view over TestPrefs. +// +// Its methods should only be called if `Valid()` returns true. +type TestPrefsView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *TestPrefs +} + +// Valid reports whether underlying value is non-nil. +func (v TestPrefsView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v TestPrefsView) AsStruct() *TestPrefs { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v TestPrefsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *TestPrefsView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x TestPrefs + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v TestPrefsView) Int32Item() Item[int32] { return v.ж.Int32Item } +func (v TestPrefsView) UInt64Item() Item[uint64] { return v.ж.UInt64Item } +func (v TestPrefsView) StringItem1() Item[string] { return v.ж.StringItem1 } +func (v TestPrefsView) StringItem2() Item[string] { return v.ж.StringItem2 } +func (v TestPrefsView) BoolItem1() Item[bool] { return v.ж.BoolItem1 } +func (v TestPrefsView) BoolItem2() Item[bool] { return v.ж.BoolItem2 } +func (v TestPrefsView) StringSlice() ListView[string] { return v.ж.StringSlice.View() } +func (v TestPrefsView) IntSlice() ListView[int] { return v.ж.IntSlice.View() } +func (v TestPrefsView) AddrItem() Item[netip.Addr] { return v.ж.AddrItem } +func (v TestPrefsView) StringStringMap() MapView[string, string] { return v.ж.StringStringMap.View() } +func (v TestPrefsView) IntStringMap() MapView[int, string] { return v.ж.IntStringMap.View() } +func (v TestPrefsView) AddrIntMap() MapView[netip.Addr, int] { return v.ж.AddrIntMap.View() } +func (v TestPrefsView) Bundle1() ItemView[*TestBundle, TestBundleView] { + return ItemViewOf(&v.ж.Bundle1) +} +func (v TestPrefsView) Bundle2() ItemView[*TestBundle, TestBundleView] { + return ItemViewOf(&v.ж.Bundle2) +} +func (v TestPrefsView) Generic() ItemView[*TestGenericStruct[int], TestGenericStructView[int]] { + return ItemViewOf(&v.ж.Generic) +} +func (v TestPrefsView) BundleList() StructListView[*TestBundle, TestBundleView] { + return StructListViewOf(&v.ж.BundleList) +} +func (v TestPrefsView) StringBundleMap() StructMapView[string, *TestBundle, TestBundleView] { + return StructMapViewOf(&v.ж.StringBundleMap) +} +func (v TestPrefsView) IntBundleMap() StructMapView[int, *TestBundle, TestBundleView] { + return StructMapViewOf(&v.ж.IntBundleMap) +} +func (v TestPrefsView) AddrBundleMap() StructMapView[netip.Addr, *TestBundle, TestBundleView] { + return StructMapViewOf(&v.ж.AddrBundleMap) +} +func (v TestPrefsView) Group() TestPrefsGroup { return v.ж.Group } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestPrefsViewNeedsRegeneration = TestPrefs(struct { + Int32Item Item[int32] + UInt64Item Item[uint64] + StringItem1 Item[string] + StringItem2 Item[string] + BoolItem1 Item[bool] + BoolItem2 Item[bool] + StringSlice List[string] + IntSlice List[int] + AddrItem Item[netip.Addr] + StringStringMap Map[string, string] + IntStringMap Map[int, string] + AddrIntMap Map[netip.Addr, int] + Bundle1 Item[*TestBundle] + Bundle2 Item[*TestBundle] + Generic Item[*TestGenericStruct[int]] + BundleList StructList[*TestBundle] + StringBundleMap StructMap[string, *TestBundle] + IntBundleMap StructMap[int, *TestBundle] + AddrBundleMap StructMap[netip.Addr, *TestBundle] + Group TestPrefsGroup +}{}) + +// View returns a readonly view of TestBundle. +func (p *TestBundle) View() TestBundleView { + return TestBundleView{ж: p} +} + +// TestBundleView provides a read-only view over TestBundle. +// +// Its methods should only be called if `Valid()` returns true. +type TestBundleView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *TestBundle +} + +// Valid reports whether underlying value is non-nil. +func (v TestBundleView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v TestBundleView) AsStruct() *TestBundle { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v TestBundleView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *TestBundleView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x TestBundle + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v TestBundleView) Name() string { return v.ж.Name } +func (v TestBundleView) Nested() *TestValueStruct { + if v.ж.Nested == nil { + return nil + } + x := *v.ж.Nested + return &x +} + +func (v TestBundleView) Equal(v2 TestBundleView) bool { return v.ж.Equal(v2.ж) } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestBundleViewNeedsRegeneration = TestBundle(struct { + Name string + Nested *TestValueStruct +}{}) + +// View returns a readonly view of TestValueStruct. +func (p *TestValueStruct) View() TestValueStructView { + return TestValueStructView{ж: p} +} + +// TestValueStructView provides a read-only view over TestValueStruct. +// +// Its methods should only be called if `Valid()` returns true. +type TestValueStructView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *TestValueStruct +} + +// Valid reports whether underlying value is non-nil. +func (v TestValueStructView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v TestValueStructView) AsStruct() *TestValueStruct { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v TestValueStructView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *TestValueStructView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x TestValueStruct + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v TestValueStructView) Value() int { return v.ж.Value } +func (v TestValueStructView) Equal(v2 TestValueStructView) bool { return v.ж.Equal(v2.ж) } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestValueStructViewNeedsRegeneration = TestValueStruct(struct { + Value int +}{}) + +// View returns a readonly view of TestGenericStruct. +func (p *TestGenericStruct[T]) View() TestGenericStructView[T] { + return TestGenericStructView[T]{ж: p} +} + +// TestGenericStructView[T] provides a read-only view over TestGenericStruct[T]. +// +// Its methods should only be called if `Valid()` returns true. +type TestGenericStructView[T ImmutableType] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *TestGenericStruct[T] +} + +// Valid reports whether underlying value is non-nil. +func (v TestGenericStructView[T]) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v TestGenericStructView[T]) AsStruct() *TestGenericStruct[T] { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v TestGenericStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *TestGenericStructView[T]) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x TestGenericStruct[T] + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v TestGenericStructView[T]) Value() T { return v.ж.Value } +func (v TestGenericStructView[T]) Equal(v2 TestGenericStructView[T]) bool { return v.ж.Equal(v2.ж) } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +func _TestGenericStructViewNeedsRegeneration[T ImmutableType](TestGenericStruct[T]) { + _TestGenericStructViewNeedsRegeneration(struct { + Value T + }{}) +} + +// View returns a readonly view of TestPrefsGroup. +func (p *TestPrefsGroup) View() TestPrefsGroupView { + return TestPrefsGroupView{ж: p} +} + +// TestPrefsGroupView provides a read-only view over TestPrefsGroup. +// +// Its methods should only be called if `Valid()` returns true. +type TestPrefsGroupView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *TestPrefsGroup +} + +// Valid reports whether underlying value is non-nil. +func (v TestPrefsGroupView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v TestPrefsGroupView) AsStruct() *TestPrefsGroup { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v TestPrefsGroupView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *TestPrefsGroupView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x TestPrefsGroup + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v TestPrefsGroupView) FloatItem() Item[float64] { return v.ж.FloatItem } +func (v TestPrefsGroupView) TestStringItem() Item[TestStringType] { return v.ж.TestStringItem } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _TestPrefsGroupViewNeedsRegeneration = TestPrefsGroup(struct { + FloatItem Item[float64] + TestStringItem Item[TestStringType] +}{}) diff --git a/types/prefs/struct_list.go b/types/prefs/struct_list.go new file mode 100644 index 000000000..872cb2326 --- /dev/null +++ b/types/prefs/struct_list.go @@ -0,0 +1,195 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "fmt" + "reflect" + "slices" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "tailscale.com/types/opt" + "tailscale.com/types/ptr" + "tailscale.com/types/views" +) + +// StructList is a preference type that holds zero or more potentially mutable struct values. +type StructList[T views.Cloner[T]] struct { + preference[[]T] +} + +// StructListOf returns a [StructList] configured with the specified value and [Options]. +func StructListOf[T views.Cloner[T]](v []T, opts ...Options) StructList[T] { + return StructList[T]{preferenceOf(opt.ValueOf(deepCloneSlice(v)), opts...)} +} + +// StructListWithOpts returns an unconfigured [StructList] with the specified [Options]. +func StructListWithOpts[T views.Cloner[T]](opts ...Options) StructList[T] { + return StructList[T]{preferenceOf(opt.Value[[]T]{}, opts...)} +} + +// SetValue configures the preference with the specified value. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (l *StructList[T]) SetValue(val []T) error { + return l.preference.SetValue(deepCloneSlice(val)) +} + +// SetManagedValue configures the preference with the specified value +// and marks the preference as managed. +func (l *StructList[T]) SetManagedValue(val []T) { + l.preference.SetManagedValue(deepCloneSlice(val)) +} + +// Clone returns a copy of l that aliases no memory with l. +func (l StructList[T]) Clone() *StructList[T] { + res := ptr.To(l) + if v, ok := l.s.Value.GetOk(); ok { + res.s.Value.Set(deepCloneSlice(v)) + } + return res +} + +// Equal reports whether l and l2 are equal. +// If the template type T implements an Equal(T) bool method, it will be used +// instead of the == operator for value comparison. +// It panics if T is not comparable. +func (l StructList[T]) Equal(l2 StructList[T]) bool { + if l.s.Metadata != l2.s.Metadata { + return false + } + v1, ok1 := l.s.Value.GetOk() + v2, ok2 := l2.s.Value.GetOk() + if ok1 != ok2 { + return false + } + if ok1 != ok2 { + return false + } + return !ok1 || slices.EqualFunc(v1, v2, comparerFor[T]()) +} + +func deepCloneSlice[T views.Cloner[T]](s []T) []T { + c := make([]T, len(s)) + for i := range s { + c[i] = s[i].Clone() + } + return c +} + +type equatable[T any] interface { + Equal(other T) bool +} + +func comparerFor[T any]() func(a, b T) bool { + switch t := reflect.TypeFor[T](); { + case t.Implements(reflect.TypeFor[equatable[T]]()): + return func(a, b T) bool { return any(a).(equatable[T]).Equal(b) } + case t.Comparable(): + return func(a, b T) bool { return any(a) == any(b) } + default: + panic(fmt.Errorf("%v is not comparable", t)) + } +} + +// StructListView is a read-only view of a [StructList]. +type StructListView[T views.ViewCloner[T, V], V views.StructView[T]] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *StructList[T] +} + +// StructListViewOf returns a read-only view of l. +// It is used by [tailscale.com/cmd/viewer]. +func StructListViewOf[T views.ViewCloner[T, V], V views.StructView[T]](l *StructList[T]) StructListView[T, V] { + return StructListView[T, V]{l} +} + +// Valid reports whether the underlying [StructList] is non-nil. +func (lv StructListView[T, V]) Valid() bool { + return lv.ж != nil +} + +// AsStruct implements [views.StructView] by returning a clone of the preference +// which aliases no memory with the original. +func (lv StructListView[T, V]) AsStruct() *StructList[T] { + if lv.ж == nil { + return nil + } + return lv.ж.Clone() +} + +// IsSet reports whether the preference has a value set. +func (lv StructListView[T, V]) IsSet() bool { + return lv.ж.IsSet() +} + +// Value returns a read-only view of the value if the preference has a value set. +// Otherwise, it returns a read-only view of its default value. +func (lv StructListView[T, V]) Value() views.SliceView[T, V] { + return views.SliceOfViews(lv.ж.Value()) +} + +// ValueOk returns a read-only view of the value and true if the preference has a value set. +// Otherwise, it returns an invalid view and false. +func (lv StructListView[T, V]) ValueOk() (val views.SliceView[T, V], ok bool) { + if v, ok := lv.ж.ValueOk(); ok { + return views.SliceOfViews(v), true + } + return views.SliceView[T, V]{}, false +} + +// DefaultValue returns a read-only view of the default value of the preference. +func (lv StructListView[T, V]) DefaultValue() views.SliceView[T, V] { + return views.SliceOfViews(lv.ж.DefaultValue()) +} + +// IsManaged reports whether the preference is managed via MDM, Group Policy, or similar means. +func (lv StructListView[T, V]) IsManaged() bool { + return lv.ж.IsManaged() +} + +// IsReadOnly reports whether the preference is read-only and cannot be changed by user. +func (lv StructListView[T, V]) IsReadOnly() bool { + return lv.ж.IsReadOnly() +} + +// Equal reports whether iv and iv2 are equal. +func (lv StructListView[T, V]) Equal(lv2 StructListView[T, V]) bool { + if !lv.Valid() && !lv2.Valid() { + return true + } + if lv.Valid() != lv2.Valid() { + return false + } + return lv.ж.Equal(*lv2.ж) +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (lv StructListView[T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return lv.ж.MarshalJSONV2(out, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (lv *StructListView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + var x StructList[T] + if err := x.UnmarshalJSONV2(in, opts); err != nil { + return err + } + lv.ж = &x + return nil +} + +// MarshalJSON implements [json.Marshaler]. +func (lv StructListView[T, V]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(lv) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (lv *StructListView[T, V]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONV2 +} diff --git a/types/prefs/struct_map.go b/types/prefs/struct_map.go new file mode 100644 index 000000000..2003eebe3 --- /dev/null +++ b/types/prefs/struct_map.go @@ -0,0 +1,175 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package prefs + +import ( + "maps" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "tailscale.com/types/opt" + "tailscale.com/types/ptr" + "tailscale.com/types/views" +) + +// StructMap is a preference type that holds potentially mutable key-value pairs. +type StructMap[K MapKeyType, V views.Cloner[V]] struct { + preference[map[K]V] +} + +// StructMapOf returns a [StructMap] configured with the specified value and [Options]. +func StructMapOf[K MapKeyType, V views.Cloner[V]](v map[K]V, opts ...Options) StructMap[K, V] { + return StructMap[K, V]{preferenceOf(opt.ValueOf(deepCloneMap(v)), opts...)} +} + +// StructMapWithOpts returns an unconfigured [StructMap] with the specified [Options]. +func StructMapWithOpts[K MapKeyType, V views.Cloner[V]](opts ...Options) StructMap[K, V] { + return StructMap[K, V]{preferenceOf(opt.Value[map[K]V]{}, opts...)} +} + +// SetValue configures the preference with the specified value. +// It fails and returns [ErrManaged] if p is a managed preference, +// and [ErrReadOnly] if p is a read-only preference. +func (l *StructMap[K, V]) SetValue(val map[K]V) error { + return l.preference.SetValue(deepCloneMap(val)) +} + +// SetManagedValue configures the preference with the specified value +// and marks the preference as managed. +func (l *StructMap[K, V]) SetManagedValue(val map[K]V) { + l.preference.SetManagedValue(deepCloneMap(val)) +} + +// Clone returns a copy of m that aliases no memory with m. +func (m StructMap[K, V]) Clone() *StructMap[K, V] { + res := ptr.To(m) + if v, ok := m.s.Value.GetOk(); ok { + res.s.Value.Set(deepCloneMap(v)) + } + return res +} + +// Equal reports whether m and m2 are equal. +// If the template type V implements an Equal(V) bool method, it will be used +// instead of the == operator for value comparison. +// It panics if T is not comparable. +func (m StructMap[K, V]) Equal(m2 StructMap[K, V]) bool { + if m.s.Metadata != m2.s.Metadata { + return false + } + v1, ok1 := m.s.Value.GetOk() + v2, ok2 := m2.s.Value.GetOk() + if ok1 != ok2 { + return false + } + return !ok1 || maps.EqualFunc(v1, v2, comparerFor[V]()) +} + +func deepCloneMap[K comparable, V views.Cloner[V]](m map[K]V) map[K]V { + c := make(map[K]V, len(m)) + for i := range m { + c[i] = m[i].Clone() + } + return c +} + +// StructMapView is a read-only view of a [StructMap]. +type StructMapView[K MapKeyType, T views.ViewCloner[T, V], V views.StructView[T]] struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *StructMap[K, T] +} + +// StructMapViewOf returns a readonly view of m. +// It is used by [tailscale.com/cmd/viewer]. +func StructMapViewOf[K MapKeyType, T views.ViewCloner[T, V], V views.StructView[T]](m *StructMap[K, T]) StructMapView[K, T, V] { + return StructMapView[K, T, V]{m} +} + +// Valid reports whether the underlying [StructMap] is non-nil. +func (mv StructMapView[K, T, V]) Valid() bool { + return mv.ж != nil +} + +// AsStruct implements [views.StructView] by returning a clone of the preference +// which aliases no memory with the original. +func (mv StructMapView[K, T, V]) AsStruct() *StructMap[K, T] { + if mv.ж == nil { + return nil + } + return mv.ж.Clone() +} + +// IsSet reports whether the preference has a value set. +func (mv StructMapView[K, T, V]) IsSet() bool { + return mv.ж.IsSet() +} + +// Value returns a read-only view of the value if the preference has a value set. +// Otherwise, it returns a read-only view of its default value. +func (mv StructMapView[K, T, V]) Value() views.MapFn[K, T, V] { + return views.MapFnOf(mv.ж.Value(), func(t T) V { return t.View() }) +} + +// ValueOk returns a read-only view of the value and true if the preference has a value set. +// Otherwise, it returns an invalid view and false. +func (mv StructMapView[K, T, V]) ValueOk() (val views.MapFn[K, T, V], ok bool) { + if v, ok := mv.ж.ValueOk(); ok { + return views.MapFnOf(v, func(t T) V { return t.View() }), true + } + return views.MapFn[K, T, V]{}, false +} + +// DefaultValue returns a read-only view of the default value of the preference. +func (mv StructMapView[K, T, V]) DefaultValue() views.MapFn[K, T, V] { + return views.MapFnOf(mv.ж.DefaultValue(), func(t T) V { return t.View() }) +} + +// Managed reports whether the preference is managed via MDM, Group Policy, or similar means. +func (mv StructMapView[K, T, V]) IsManaged() bool { + return mv.ж.IsManaged() +} + +// ReadOnly reports whether the preference is read-only and cannot be changed by user. +func (mv StructMapView[K, T, V]) IsReadOnly() bool { + return mv.ж.IsReadOnly() +} + +// Equal reports whether mv and mv2 are equal. +func (mv StructMapView[K, T, V]) Equal(mv2 StructMapView[K, T, V]) bool { + if !mv.Valid() && !mv2.Valid() { + return true + } + if mv.Valid() != mv2.Valid() { + return false + } + return mv.ж.Equal(*mv2.ж) +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (mv StructMapView[K, T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { + return mv.ж.MarshalJSONV2(out, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (mv *StructMapView[K, T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { + var x StructMap[K, T] + if err := x.UnmarshalJSONV2(in, opts); err != nil { + return err + } + mv.ж = &x + return nil +} + +// MarshalJSON implements [json.Marshaler]. +func (mv StructMapView[K, T, V]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(mv) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (mv *StructMapView[K, T, V]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONV2 +}