mirror of https://github.com/tailscale/tailscale/
util/syspolicy/setting: add package that contains types for the next syspolicy PRs
Package setting contains types for defining and representing policy settings. It facilitates the registration of setting definitions using Register and RegisterDefinition, and the retrieval of registered setting definitions via Definitions and DefinitionOf. This package is intended for use primarily within the syspolicy package hierarchy, and added in a preparation for the next PRs. Updates #12687 Signed-off-by: Nick Khyl <nickk@tailscale.com>pull/13115/head
parent
a61825c7b8
commit
67df9abdc6
@ -0,0 +1,63 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package internal contains miscellaneous functions and types
|
||||||
|
// that are internal to the syspolicy packages.
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"github.com/go-json-experiment/json/jsontext"
|
||||||
|
"tailscale.com/types/lazy"
|
||||||
|
"tailscale.com/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OSForTesting is the operating system override used for testing.
|
||||||
|
// It follows the same naming convention as [version.OS].
|
||||||
|
var OSForTesting lazy.SyncValue[string]
|
||||||
|
|
||||||
|
// OS is like [version.OS], but supports a test hook.
|
||||||
|
func OS() string {
|
||||||
|
return OSForTesting.Get(version.OS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TB is a subset of testing.TB that we use to set up test helpers.
|
||||||
|
// It's defined here to avoid pulling in the testing package.
|
||||||
|
type TB interface {
|
||||||
|
Helper()
|
||||||
|
Cleanup(func())
|
||||||
|
Logf(format string, args ...any)
|
||||||
|
Error(args ...any)
|
||||||
|
Errorf(format string, args ...any)
|
||||||
|
Fatal(args ...any)
|
||||||
|
Fatalf(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualJSONForTest compares the JSON in j1 and j2 for semantic equality.
|
||||||
|
// It returns "", "", true if j1 and j2 are equal. Otherwise, it returns
|
||||||
|
// indented versions of j1 and j2 and false.
|
||||||
|
func EqualJSONForTest(tb TB, j1, j2 jsontext.Value) (s1, s2 string, equal bool) {
|
||||||
|
tb.Helper()
|
||||||
|
j1 = j1.Clone()
|
||||||
|
j2 = j2.Clone()
|
||||||
|
// Canonicalize JSON values for comparison.
|
||||||
|
if err := j1.Canonicalize(); err != nil {
|
||||||
|
tb.Error(err)
|
||||||
|
}
|
||||||
|
if err := j2.Canonicalize(); err != nil {
|
||||||
|
tb.Error(err)
|
||||||
|
}
|
||||||
|
// Check and return true if the two values are structurally equal.
|
||||||
|
if bytes.Equal(j1, j2) {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
// Otherwise, format the values for display and return false.
|
||||||
|
if err := j1.Indent("", "\t"); err != nil {
|
||||||
|
tb.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := j2.Indent("", "\t"); err != nil {
|
||||||
|
tb.Fatal(err)
|
||||||
|
}
|
||||||
|
return j1.String(), j2.String(), false
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"tailscale.com/types/ptr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotConfigured is returned when the requested policy setting is not configured.
|
||||||
|
ErrNotConfigured = errors.New("not configured")
|
||||||
|
// ErrTypeMismatch is returned when there's a type mismatch between the actual type
|
||||||
|
// of the setting value and the expected type.
|
||||||
|
ErrTypeMismatch = errors.New("type mismatch")
|
||||||
|
// ErrNoSuchKey is returned by [DefinitionOf] when no policy setting
|
||||||
|
// has been registered with the specified key.
|
||||||
|
//
|
||||||
|
// Until 2024-08-02, this error was also returned by a [Handler] when the specified
|
||||||
|
// key did not have a value set. While the package maintains compatibility with this
|
||||||
|
// usage of ErrNoSuchKey, it is recommended to return [ErrNotConfigured] from newer
|
||||||
|
// [source.Store] implementations.
|
||||||
|
ErrNoSuchKey = errors.New("no such key")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorText represents an error that occurs when reading or parsing a policy setting.
|
||||||
|
// This includes errors due to permissions issues, value type and format mismatches,
|
||||||
|
// and other platform- or source-specific errors. It does not include
|
||||||
|
// [ErrNotConfigured] and [ErrNoSuchKey], as those correspond to unconfigured
|
||||||
|
// policy settings rather than settings that cannot be read or parsed
|
||||||
|
// due to an error.
|
||||||
|
//
|
||||||
|
// ErrorText is used to marshal errors when a policy setting is sent over the wire,
|
||||||
|
// allowing the error to be logged or displayed. It does not preserve the
|
||||||
|
// type information of the underlying error.
|
||||||
|
type ErrorText string
|
||||||
|
|
||||||
|
// NewErrorText returns a [ErrorText] with the specified error message.
|
||||||
|
func NewErrorText(text string) *ErrorText {
|
||||||
|
return ptr.To(ErrorText(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorTextFromError returns an [ErrorText] with the text of the specified error,
|
||||||
|
// or nil if err is nil, [ErrNotConfigured], or [ErrNoSuchKey].
|
||||||
|
func NewErrorTextFromError(err error) *ErrorText {
|
||||||
|
if err == nil || errors.Is(err, ErrNotConfigured) || errors.Is(err, ErrNoSuchKey) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err, ok := err.(*ErrorText); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ptr.To(ErrorText(err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements error.
|
||||||
|
func (e ErrorText) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextMarshaler].
|
||||||
|
func (e ErrorText) MarshalText() (text []byte, err error) {
|
||||||
|
return []byte(e.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements [encoding.TextUnmarshaler].
|
||||||
|
func (e *ErrorText) UnmarshalText(text []byte) error {
|
||||||
|
*e = ErrorText(text)
|
||||||
|
return nil
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
// Key is a string that uniquely identifies a policy and must remain unchanged
|
||||||
|
// once established and documented for a given policy setting. It may contain
|
||||||
|
// alphanumeric characters and zero or more [KeyPathSeparator]s to group
|
||||||
|
// individual policy settings into categories.
|
||||||
|
type Key string
|
||||||
|
|
||||||
|
// KeyPathSeparator allows logical grouping of policy settings into categories.
|
||||||
|
const KeyPathSeparator = "/"
|
@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/go-json-experiment/json/jsontext"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Origin describes where a policy or a policy setting is configured.
|
||||||
|
type Origin struct {
|
||||||
|
data settingOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingOrigin is the marshallable data of an [Origin].
|
||||||
|
type settingOrigin struct {
|
||||||
|
Name string `json:",omitzero"`
|
||||||
|
Scope PolicyScope
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrigin returns a new [Origin] with the specified scope.
|
||||||
|
func NewOrigin(scope PolicyScope) *Origin {
|
||||||
|
return NewNamedOrigin("", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNamedOrigin returns a new [Origin] with the specified scope and name.
|
||||||
|
func NewNamedOrigin(name string, scope PolicyScope) *Origin {
|
||||||
|
return &Origin{settingOrigin{name, scope}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope reports the policy [PolicyScope] where the setting is configured.
|
||||||
|
func (s Origin) Scope() PolicyScope {
|
||||||
|
return s.data.Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of the policy source where the setting is configured,
|
||||||
|
// or "" if not available.
|
||||||
|
func (s Origin) Name() string {
|
||||||
|
return s.data.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (s Origin) String() string {
|
||||||
|
if s.Name() != "" {
|
||||||
|
return fmt.Sprintf("%s (%v)", s.Name(), s.Scope())
|
||||||
|
}
|
||||||
|
return s.Scope().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||||
|
func (s Origin) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||||
|
return jsonv2.MarshalEncode(out, &s.data, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||||
|
func (s *Origin) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||||
|
return jsonv2.UnmarshalDecode(in, &s.data, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements [json.Marshaler].
|
||||||
|
func (s Origin) MarshalJSON() ([]byte, error) {
|
||||||
|
return jsonv2.Marshal(s) // uses MarshalJSONV2
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements [json.Unmarshaler].
|
||||||
|
func (s *Origin) UnmarshalJSON(b []byte) error {
|
||||||
|
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
|
||||||
|
}
|
@ -0,0 +1,189 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/types/lazy"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
lazyDefaultScope lazy.SyncValue[PolicyScope]
|
||||||
|
|
||||||
|
// DeviceScope indicates a scope containing device-global policies.
|
||||||
|
DeviceScope = PolicyScope{kind: DeviceSetting}
|
||||||
|
// CurrentProfileScope indicates a scope containing policies that apply to the
|
||||||
|
// currently active Tailscale profile.
|
||||||
|
CurrentProfileScope = PolicyScope{kind: ProfileSetting}
|
||||||
|
// CurrentUserScope indicates a scope containing policies that apply to the
|
||||||
|
// current user, for whatever that means on the current platform and
|
||||||
|
// in the current application context.
|
||||||
|
CurrentUserScope = PolicyScope{kind: UserSetting}
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyScope is a management scope.
|
||||||
|
type PolicyScope struct {
|
||||||
|
kind Scope
|
||||||
|
userID string
|
||||||
|
profileID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultScope returns the default [PolicyScope] to be used by a program
|
||||||
|
// when querying policy settings.
|
||||||
|
// It returns [DeviceScope], unless explicitly changed with [SetDefaultScope].
|
||||||
|
func DefaultScope() PolicyScope {
|
||||||
|
return lazyDefaultScope.Get(func() PolicyScope { return DeviceScope })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultScope attempts to set the specified scope as the default scope
|
||||||
|
// to be used by a program when querying policy settings.
|
||||||
|
// It fails and returns false if called more than once, or if the [DefaultScope]
|
||||||
|
// has already been used.
|
||||||
|
func SetDefaultScope(scope PolicyScope) bool {
|
||||||
|
return lazyDefaultScope.Set(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserScopeOf returns a policy [PolicyScope] of the user with the specified id.
|
||||||
|
func UserScopeOf(uid string) PolicyScope {
|
||||||
|
return PolicyScope{kind: UserSetting, userID: uid}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind reports the scope kind of s.
|
||||||
|
func (s PolicyScope) Kind() Scope {
|
||||||
|
return s.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsApplicableSetting reports whether the specified setting applies to
|
||||||
|
// and can be retrieved for this scope. Policy settings are applicable
|
||||||
|
// to their own scopes as well as more specific scopes. For example,
|
||||||
|
// device settings are applicable to device, profile and user scopes,
|
||||||
|
// but user settings are only applicable to user scopes.
|
||||||
|
// For instance, a menu visibility setting is inherently a user setting
|
||||||
|
// and only makes sense in the context of a specific user.
|
||||||
|
func (s PolicyScope) IsApplicableSetting(setting *Definition) bool {
|
||||||
|
return setting != nil && setting.Scope() <= s.Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfigurableSetting reports whether the specified setting can be configured
|
||||||
|
// by a policy at this scope. Policy settings are configurable at their own scopes
|
||||||
|
// as well as broader scopes. For example, [UserSetting]s are configurable in
|
||||||
|
// user, profile, and device scopes, but [DeviceSetting]s are only configurable
|
||||||
|
// in the [DeviceScope]. For instance, the InstallUpdates policy setting
|
||||||
|
// can only be configured in the device scope, as it controls whether updates
|
||||||
|
// will be installed automatically on the device, rather than for specific users.
|
||||||
|
func (s PolicyScope) IsConfigurableSetting(setting *Definition) bool {
|
||||||
|
return setting != nil && setting.Scope() >= s.Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether policy settings that apply to s also apply to s2.
|
||||||
|
// For example, policy settings that apply to the [DeviceScope] also apply to
|
||||||
|
// the [CurrentUserScope].
|
||||||
|
func (s PolicyScope) Contains(s2 PolicyScope) bool {
|
||||||
|
if s.Kind() > s2.Kind() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch s.Kind() {
|
||||||
|
case DeviceSetting:
|
||||||
|
return true
|
||||||
|
case ProfileSetting:
|
||||||
|
return s.profileID == s2.profileID
|
||||||
|
case UserSetting:
|
||||||
|
return s.userID == s2.userID
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrictlyContains is like [PolicyScope.Contains], but returns false
|
||||||
|
// when s and s2 is the same scope.
|
||||||
|
func (s PolicyScope) StrictlyContains(s2 PolicyScope) bool {
|
||||||
|
return s != s2 && s.Contains(s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (s PolicyScope) String() string {
|
||||||
|
if s.profileID == "" && s.userID == "" {
|
||||||
|
return s.kind.String()
|
||||||
|
}
|
||||||
|
return s.stringSlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextMarshaler].
|
||||||
|
func (s PolicyScope) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(s.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextUnmarshaler].
|
||||||
|
func (s *PolicyScope) UnmarshalText(b []byte) error {
|
||||||
|
*s = PolicyScope{}
|
||||||
|
parts := strings.SplitN(string(b), "/", 2)
|
||||||
|
for i, part := range parts {
|
||||||
|
kind, id, err := parseScopeAndID(part)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if i > 0 && kind <= s.kind {
|
||||||
|
return fmt.Errorf("invalid scope hierarchy: %s", b)
|
||||||
|
}
|
||||||
|
s.kind = kind
|
||||||
|
switch kind {
|
||||||
|
case DeviceSetting:
|
||||||
|
if id != "" {
|
||||||
|
return fmt.Errorf("the device scope must not have an ID: %s", b)
|
||||||
|
}
|
||||||
|
case ProfileSetting:
|
||||||
|
s.profileID = id
|
||||||
|
case UserSetting:
|
||||||
|
s.userID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s PolicyScope) stringSlow() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeScopeWithID := func(s Scope, id string) {
|
||||||
|
sb.WriteString(s.String())
|
||||||
|
if id != "" {
|
||||||
|
sb.WriteRune('(')
|
||||||
|
sb.WriteString(id)
|
||||||
|
sb.WriteRune(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.kind == ProfileSetting || s.profileID != "" {
|
||||||
|
writeScopeWithID(ProfileSetting, s.profileID)
|
||||||
|
if s.kind != ProfileSetting {
|
||||||
|
sb.WriteRune('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.kind == UserSetting {
|
||||||
|
writeScopeWithID(UserSetting, s.userID)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScopeAndID(s string) (scope Scope, id string, err error) {
|
||||||
|
name, params, ok := extractScopeAndParams(s)
|
||||||
|
if !ok {
|
||||||
|
return 0, "", fmt.Errorf("%q is not a valid scope string", s)
|
||||||
|
}
|
||||||
|
if err := scope.UnmarshalText([]byte(name)); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
return scope, params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractScopeAndParams(s string) (name, params string, ok bool) {
|
||||||
|
paramsStart := strings.Index(s, "(")
|
||||||
|
if paramsStart == -1 {
|
||||||
|
return s, "", true
|
||||||
|
}
|
||||||
|
paramsEnd := strings.LastIndex(s, ")")
|
||||||
|
if paramsEnd < paramsStart {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return s[0:paramsStart], s[paramsStart+1 : paramsEnd], true
|
||||||
|
}
|
@ -0,0 +1,565 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPolicyScopeIsApplicableSetting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scope PolicyScope
|
||||||
|
setting *Definition
|
||||||
|
wantApplicable bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DeviceScope/DeviceSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/ProfileSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantApplicable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/UserSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantApplicable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/DeviceSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/ProfileSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/UserSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantApplicable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/DeviceSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/ProfileSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/UserSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantApplicable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotApplicable := tt.scope.IsApplicableSetting(tt.setting)
|
||||||
|
if gotApplicable != tt.wantApplicable {
|
||||||
|
t.Fatalf("got %v, want %v", gotApplicable, tt.wantApplicable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyScopeIsConfigurableSetting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scope PolicyScope
|
||||||
|
setting *Definition
|
||||||
|
wantConfigurable bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DeviceScope/DeviceSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/ProfileSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/UserSetting",
|
||||||
|
scope: DeviceScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/DeviceSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantConfigurable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/ProfileSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/UserSetting",
|
||||||
|
scope: CurrentProfileScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/DeviceSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
|
||||||
|
wantConfigurable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/ProfileSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
|
||||||
|
wantConfigurable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/UserSetting",
|
||||||
|
scope: CurrentUserScope,
|
||||||
|
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
|
||||||
|
wantConfigurable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotConfigurable := tt.scope.IsConfigurableSetting(tt.setting)
|
||||||
|
if gotConfigurable != tt.wantConfigurable {
|
||||||
|
t.Fatalf("got %v, want %v", gotConfigurable, tt.wantConfigurable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyScopeContains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scopeA PolicyScope
|
||||||
|
scopeB PolicyScope
|
||||||
|
wantAContainsB bool
|
||||||
|
wantAStrictlyContainsB bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DeviceScope/DeviceScope",
|
||||||
|
scopeA: DeviceScope,
|
||||||
|
scopeB: DeviceScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/CurrentProfileScope",
|
||||||
|
scopeA: DeviceScope,
|
||||||
|
scopeB: CurrentProfileScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeviceScope/UserScope",
|
||||||
|
scopeA: DeviceScope,
|
||||||
|
scopeB: CurrentUserScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/DeviceScope",
|
||||||
|
scopeA: CurrentProfileScope,
|
||||||
|
scopeB: DeviceScope,
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/ProfileScope",
|
||||||
|
scopeA: CurrentProfileScope,
|
||||||
|
scopeB: CurrentProfileScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope/UserScope",
|
||||||
|
scopeA: CurrentProfileScope,
|
||||||
|
scopeB: CurrentUserScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/DeviceScope",
|
||||||
|
scopeA: CurrentUserScope,
|
||||||
|
scopeB: DeviceScope,
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/ProfileScope",
|
||||||
|
scopeA: CurrentUserScope,
|
||||||
|
scopeB: CurrentProfileScope,
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope/UserScope",
|
||||||
|
scopeA: CurrentUserScope,
|
||||||
|
scopeB: CurrentUserScope,
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope(1234)/UserScope(1234)",
|
||||||
|
scopeA: UserScopeOf("1234"),
|
||||||
|
scopeB: UserScopeOf("1234"),
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope(1234)/UserScope(5678)",
|
||||||
|
scopeA: UserScopeOf("1234"),
|
||||||
|
scopeB: UserScopeOf("5678"),
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope(A)/UserScope(A/1234)",
|
||||||
|
scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"},
|
||||||
|
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"},
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ProfileScope(A)/UserScope(B/1234)",
|
||||||
|
scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"},
|
||||||
|
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "B"},
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope(1234)/UserScope(A/1234)",
|
||||||
|
scopeA: PolicyScope{kind: UserSetting, userID: "1234"},
|
||||||
|
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"},
|
||||||
|
wantAContainsB: true,
|
||||||
|
wantAStrictlyContainsB: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserScope(1234)/UserScope(A/5678)",
|
||||||
|
scopeA: PolicyScope{kind: UserSetting, userID: "1234"},
|
||||||
|
scopeB: PolicyScope{kind: UserSetting, userID: "5678", profileID: "A"},
|
||||||
|
wantAContainsB: false,
|
||||||
|
wantAStrictlyContainsB: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotContains := tt.scopeA.Contains(tt.scopeB)
|
||||||
|
if gotContains != tt.wantAContainsB {
|
||||||
|
t.Fatalf("WithinOf: got %v, want %v", gotContains, tt.wantAContainsB)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotStrictlyContains := tt.scopeA.StrictlyContains(tt.scopeB)
|
||||||
|
if gotStrictlyContains != tt.wantAStrictlyContainsB {
|
||||||
|
t.Fatalf("StrictlyWithinOf: got %v, want %v", gotStrictlyContains, tt.wantAStrictlyContainsB)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyScopeMarshalUnmarshal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
wantJSON string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "null-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{},
|
||||||
|
wantJSON: `{"Scope":"Device"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null-scope-omit-zero",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope `json:",omitzero"`
|
||||||
|
}{},
|
||||||
|
wantJSON: `{}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{DeviceScope},
|
||||||
|
wantJSON: `{"Scope":"Device"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current-profile-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{CurrentProfileScope},
|
||||||
|
wantJSON: `{"Scope":"Profile"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current-user-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{CurrentUserScope},
|
||||||
|
wantJSON: `{"Scope":"User"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific-user-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{UserScopeOf("_")},
|
||||||
|
wantJSON: `{"Scope":"User(_)"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific-user-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{UserScopeOf("S-1-5-21-3698941153-1525015703-2649197413-1001")},
|
||||||
|
wantJSON: `{"Scope":"User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific-profile-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{PolicyScope{kind: ProfileSetting, profileID: "1234"}},
|
||||||
|
wantJSON: `{"Scope":"Profile(1234)"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific-profile-and-user-scope",
|
||||||
|
in: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{PolicyScope{
|
||||||
|
kind: UserSetting,
|
||||||
|
profileID: "1234",
|
||||||
|
userID: "S-1-5-21-3698941153-1525015703-2649197413-1001",
|
||||||
|
}},
|
||||||
|
wantJSON: `{"Scope":"Profile(1234)/User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotJSON, err := jsonv2.Marshal(tt.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotJSON) != tt.wantJSON {
|
||||||
|
t.Fatalf("Marshal got %s, want %s", gotJSON, tt.wantJSON)
|
||||||
|
}
|
||||||
|
wantBack := tt.in
|
||||||
|
gotBack := reflect.New(reflect.TypeOf(tt.in).Elem()).Interface()
|
||||||
|
err = jsonv2.Unmarshal(gotJSON, gotBack)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotBack, wantBack) {
|
||||||
|
t.Fatalf("Unmarshal got %+v, want %+v", gotBack, wantBack)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyScopeUnmarshalSpecial(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
want any
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
json: "{}",
|
||||||
|
want: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too-many-scopes",
|
||||||
|
json: `{"Scope":"Device/Profile/User"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user/profile", // incorrect order
|
||||||
|
json: `{"Scope":"User/Profile"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profile-user-no-params",
|
||||||
|
json: `{"Scope":"Profile/User"}`,
|
||||||
|
want: &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{CurrentUserScope},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown-scope",
|
||||||
|
json: `{"Scope":"Unknown"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown-scope/unknown-scope",
|
||||||
|
json: `{"Scope":"Unknown/Unknown"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device-scope/unknown-scope",
|
||||||
|
json: `{"Scope":"Device/Unknown"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown-scope/device-scope",
|
||||||
|
json: `{"Scope":"Unknown/Device"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "slash",
|
||||||
|
json: `{"Scope":"/"}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
json: `{"Scope": ""`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-closing-bracket",
|
||||||
|
json: `{"Scope": "user(1234"`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device-with-id",
|
||||||
|
json: `{"Scope": "device(123)"`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := &struct {
|
||||||
|
Scope PolicyScope
|
||||||
|
}{}
|
||||||
|
err := jsonv2.Unmarshal([]byte(tt.json), got)
|
||||||
|
if (err != nil) != tt.wantError {
|
||||||
|
t.Errorf("Marshal error: got %v, want %v", err, tt.wantError)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("Unmarshal got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractScopeAndParams(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s string
|
||||||
|
scope string
|
||||||
|
params string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
s: "",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scope-only",
|
||||||
|
s: "device",
|
||||||
|
scope: "device",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scope-with-params",
|
||||||
|
s: "user(1234)",
|
||||||
|
scope: "user",
|
||||||
|
params: "1234",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "params-empty-scope",
|
||||||
|
s: "(1234)",
|
||||||
|
scope: "",
|
||||||
|
params: "1234",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "params-with-brackets",
|
||||||
|
s: "test()())))())",
|
||||||
|
scope: "test",
|
||||||
|
params: ")())))()",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-closing-bracket",
|
||||||
|
s: "user(1234",
|
||||||
|
scope: "",
|
||||||
|
params: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "open-before-close",
|
||||||
|
s: ")user(1234",
|
||||||
|
scope: "",
|
||||||
|
params: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "brackets-only",
|
||||||
|
s: ")(",
|
||||||
|
scope: "",
|
||||||
|
params: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "closing-bracket",
|
||||||
|
s: ")",
|
||||||
|
scope: "",
|
||||||
|
params: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opening-bracket",
|
||||||
|
s: ")",
|
||||||
|
scope: "",
|
||||||
|
params: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
scope, params, ok := extractScopeAndParams(tt.s)
|
||||||
|
if ok != tt.wantOk {
|
||||||
|
t.Logf("OK: got %v; want %v", ok, tt.wantOk)
|
||||||
|
}
|
||||||
|
if scope != tt.scope {
|
||||||
|
t.Logf("Scope: got %q; want %q", scope, tt.scope)
|
||||||
|
}
|
||||||
|
if params != tt.params {
|
||||||
|
t.Logf("Params: got %v; want %v", params, tt.params)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"tailscale.com/types/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawItem contains a raw policy setting value as read from a policy store, or an
|
||||||
|
// error if the requested setting could not be read from the store. As a special
|
||||||
|
// case, it may also hold a value of the [Visibility], [PreferenceOption],
|
||||||
|
// or [time.Duration] types. While the policy store interface does not support
|
||||||
|
// these types natively, and the values of these types have to be unmarshalled
|
||||||
|
// or converted from strings, these setting types predate the typed policy
|
||||||
|
// hierarchies, and must be supported at this layer.
|
||||||
|
type RawItem struct {
|
||||||
|
_ structs.Incomparable
|
||||||
|
value any
|
||||||
|
err *ErrorText
|
||||||
|
origin *Origin // or nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawItemOf returns a [RawItem] with the specified value.
|
||||||
|
func RawItemOf(value any) RawItem {
|
||||||
|
return RawItemWith(value, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawItemWith returns a [RawItem] with the specified value, error and origin.
|
||||||
|
func RawItemWith(value any, err *ErrorText, origin *Origin) RawItem {
|
||||||
|
return RawItem{value: value, err: err, origin: origin}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the value of the policy setting, or nil if the policy setting
|
||||||
|
// is not configured, or an error occurred while reading it.
|
||||||
|
func (i RawItem) Value() any {
|
||||||
|
return i.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error that occurred when reading the policy setting,
|
||||||
|
// or nil if no error occurred.
|
||||||
|
func (i RawItem) Error() error {
|
||||||
|
if i.err != nil {
|
||||||
|
return i.err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin returns an optional [Origin] indicating where the policy setting is
|
||||||
|
// configured.
|
||||||
|
func (i RawItem) Origin() *Origin {
|
||||||
|
return i.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (i RawItem) String() string {
|
||||||
|
var suffix string
|
||||||
|
if i.origin != nil {
|
||||||
|
suffix = fmt.Sprintf(" - {%v}", i.origin)
|
||||||
|
}
|
||||||
|
if i.err != nil {
|
||||||
|
return fmt.Sprintf("Error{%q}%s", i.err.Error(), suffix)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v%s", i.value, suffix)
|
||||||
|
}
|
@ -0,0 +1,348 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package setting contains types for defining and representing policy settings.
|
||||||
|
// It facilitates the registration of setting definitions using [Register] and [RegisterDefinition],
|
||||||
|
// and the retrieval of registered setting definitions via [Definitions] and [DefinitionOf].
|
||||||
|
// This package is intended for use primarily within the syspolicy package hierarchy.
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/types/lazy"
|
||||||
|
"tailscale.com/util/syspolicy/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope indicates the broadest scope at which a policy setting may apply,
|
||||||
|
// and the narrowest scope at which it may be configured.
|
||||||
|
type Scope int8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DeviceSetting indicates a policy setting that applies to a device, regardless of
|
||||||
|
// which OS user or Tailscale profile is currently active, if any.
|
||||||
|
// It can only be configured at a [DeviceScope].
|
||||||
|
DeviceSetting Scope = iota
|
||||||
|
// ProfileSetting indicates a policy setting that applies to a Tailscale profile.
|
||||||
|
// It can only be configured for a specific profile or at a [DeviceScope],
|
||||||
|
// in which case it applies to all profiles on the device.
|
||||||
|
ProfileSetting
|
||||||
|
// UserSetting indicates a policy setting that applies to users.
|
||||||
|
// It can be configured for a user, profile, or the entire device.
|
||||||
|
UserSetting
|
||||||
|
|
||||||
|
// NumScopes is the number of possible [Scope] values.
|
||||||
|
NumScopes int = iota // must be the last value in the const block.
|
||||||
|
)
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (s Scope) String() string {
|
||||||
|
switch s {
|
||||||
|
case DeviceSetting:
|
||||||
|
return "Device"
|
||||||
|
case ProfileSetting:
|
||||||
|
return "Profile"
|
||||||
|
case UserSetting:
|
||||||
|
return "User"
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextMarshaler].
|
||||||
|
func (s Scope) MarshalText() (text []byte, err error) {
|
||||||
|
return []byte(s.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements [encoding.TextUnmarshaler].
|
||||||
|
func (s *Scope) UnmarshalText(text []byte) error {
|
||||||
|
switch strings.ToLower(string(text)) {
|
||||||
|
case "device":
|
||||||
|
*s = DeviceSetting
|
||||||
|
case "profile":
|
||||||
|
*s = ProfileSetting
|
||||||
|
case "user":
|
||||||
|
*s = UserSetting
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%q is not a valid scope", string(text))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type is a policy setting value type.
|
||||||
|
// Except for [InvalidValue], which represents an invalid policy setting type,
|
||||||
|
// and [PreferenceOptionValue], [VisibilityValue], and [DurationValue],
|
||||||
|
// which have special handling due to their legacy status in the package,
|
||||||
|
// SettingTypes represent the raw value types readable from policy stores.
|
||||||
|
type Type int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// InvalidValue indicates an invalid policy setting value type.
|
||||||
|
InvalidValue Type = iota
|
||||||
|
// BooleanValue indicates a policy setting whose underlying type is a bool.
|
||||||
|
BooleanValue
|
||||||
|
// IntegerValue indicates a policy setting whose underlying type is a uint64.
|
||||||
|
IntegerValue
|
||||||
|
// StringValue indicates a policy setting whose underlying type is a string.
|
||||||
|
StringValue
|
||||||
|
// StringListValue indicates a policy setting whose underlying type is a []string.
|
||||||
|
StringListValue
|
||||||
|
// PreferenceOptionValue indicates a three-state policy setting whose
|
||||||
|
// underlying type is a string, but the actual value is a [PreferenceOption].
|
||||||
|
PreferenceOptionValue
|
||||||
|
// VisibilityValue indicates a two-state boolean-like policy setting whose
|
||||||
|
// underlying type is a string, but the actual value is a [Visibility].
|
||||||
|
VisibilityValue
|
||||||
|
// DurationValue indicates an interval/period/duration policy setting whose
|
||||||
|
// underlying type is a string, but the actual value is a [time.Duration].
|
||||||
|
DurationValue
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a string representation of t.
|
||||||
|
func (t Type) String() string {
|
||||||
|
switch t {
|
||||||
|
case InvalidValue:
|
||||||
|
return "Invalid"
|
||||||
|
case BooleanValue:
|
||||||
|
return "Boolean"
|
||||||
|
case IntegerValue:
|
||||||
|
return "Integer"
|
||||||
|
case StringValue:
|
||||||
|
return "String"
|
||||||
|
case StringListValue:
|
||||||
|
return "StringList"
|
||||||
|
case PreferenceOptionValue:
|
||||||
|
return "PreferenceOption"
|
||||||
|
case VisibilityValue:
|
||||||
|
return "Visibility"
|
||||||
|
case DurationValue:
|
||||||
|
return "Duration"
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueType is a constraint that allows Go types corresponding to [Type].
|
||||||
|
type ValueType interface {
|
||||||
|
bool | uint64 | string | []string | Visibility | PreferenceOption | time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition defines policy key, scope and value type.
|
||||||
|
type Definition struct {
|
||||||
|
key Key
|
||||||
|
scope Scope
|
||||||
|
typ Type
|
||||||
|
platforms PlatformList
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefinition returns a new [Definition] with the specified
|
||||||
|
// key, scope, type and supported platforms (see [PlatformList]).
|
||||||
|
func NewDefinition(k Key, s Scope, t Type, platforms ...string) *Definition {
|
||||||
|
return &Definition{key: k, scope: s, typ: t, platforms: platforms}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a policy setting's identifier.
|
||||||
|
func (d *Definition) Key() Key {
|
||||||
|
if d == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope reports the broadest [Scope] the policy setting may apply to.
|
||||||
|
func (d *Definition) Scope() Scope {
|
||||||
|
if d == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type reports the underlying value type of the policy setting.
|
||||||
|
func (d *Definition) Type() Type {
|
||||||
|
if d == nil {
|
||||||
|
return InvalidValue
|
||||||
|
}
|
||||||
|
return d.typ
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSupported reports whether the policy setting is supported on the current OS.
|
||||||
|
func (d *Definition) IsSupported() bool {
|
||||||
|
if d == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.platforms.HasCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedPlatforms reports platforms on which the policy setting is supported.
|
||||||
|
// An empty [PlatformList] indicates that s is available on all platforms.
|
||||||
|
func (d *Definition) SupportedPlatforms() PlatformList {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.platforms
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (d *Definition) String() string {
|
||||||
|
if d == nil {
|
||||||
|
return "(nil)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v(%q, %v)", d.scope, d.key, d.typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal reports whether d and d2 have the same key, type and scope.
|
||||||
|
// It does not check whether both s and s2 are supported on the same platforms.
|
||||||
|
func (d *Definition) Equal(d2 *Definition) bool {
|
||||||
|
if d == d2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if d == nil || d2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.key == d2.key && d.typ == d2.typ && d.scope == d2.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefinitionMap is a map of setting [Definition] by [Key].
|
||||||
|
type DefinitionMap map[Key]*Definition
|
||||||
|
|
||||||
|
var (
|
||||||
|
definitions lazy.SyncValue[DefinitionMap]
|
||||||
|
|
||||||
|
definitionsMu sync.Mutex
|
||||||
|
definitionsList []*Definition
|
||||||
|
definitionsUsed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register registers a policy setting with the specified key, scope, value type,
|
||||||
|
// and an optional list of supported platforms. All policy settings must be
|
||||||
|
// registered before any of them can be used. Register panics if called after
|
||||||
|
// invoking any functions that use the registered policy definitions. This
|
||||||
|
// includes calling [Definitions] or [DefinitionOf] directly, or reading any
|
||||||
|
// policy settings via syspolicy.
|
||||||
|
func Register(k Key, s Scope, t Type, platforms ...string) {
|
||||||
|
RegisterDefinition(NewDefinition(k, s, t, platforms...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDefinition is like [Register], but accepts a [Definition].
|
||||||
|
func RegisterDefinition(d *Definition) {
|
||||||
|
definitionsMu.Lock()
|
||||||
|
defer definitionsMu.Unlock()
|
||||||
|
registerLocked(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerLocked(d *Definition) {
|
||||||
|
if definitionsUsed {
|
||||||
|
panic("policy definitions are already in use")
|
||||||
|
}
|
||||||
|
definitionsList = append(definitionsList, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingDefinitions() (DefinitionMap, error) {
|
||||||
|
return definitions.GetErr(func() (DefinitionMap, error) {
|
||||||
|
definitionsMu.Lock()
|
||||||
|
defer definitionsMu.Unlock()
|
||||||
|
definitionsUsed = true
|
||||||
|
return DefinitionMapOf(definitionsList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefinitionMapOf returns a [DefinitionMap] with the specified settings,
|
||||||
|
// or an error if any settings have the same key but different type or scope.
|
||||||
|
func DefinitionMapOf(settings []*Definition) (DefinitionMap, error) {
|
||||||
|
m := make(DefinitionMap, len(settings))
|
||||||
|
for _, s := range settings {
|
||||||
|
if existing, exists := m[s.key]; exists {
|
||||||
|
if existing.Equal(s) {
|
||||||
|
// Ignore duplicate setting definitions if they match. It is acceptable
|
||||||
|
// if the same policy setting was registered more than once
|
||||||
|
// (e.g. by the syspolicy package itself and by iOS/Android code).
|
||||||
|
existing.platforms.mergeFrom(s.platforms)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("duplicate policy definition: %q", s.key)
|
||||||
|
}
|
||||||
|
m[s.key] = s
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefinitionsForTest allows to register the specified setting definitions
|
||||||
|
// for the test duration. It is not concurrency-safe, but unlike [Register],
|
||||||
|
// it does not panic and can be called anytime.
|
||||||
|
// It returns an error if ds contains two different settings with the same [Key].
|
||||||
|
func SetDefinitionsForTest(tb lazy.TB, ds ...*Definition) error {
|
||||||
|
m, err := DefinitionMapOf(ds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
definitions.SetForTest(tb, m, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefinitionOf returns a setting definition by key,
|
||||||
|
// or [ErrNoSuchKey] if the specified key does not exist,
|
||||||
|
// or an error if there are conflicting policy definitions.
|
||||||
|
func DefinitionOf(k Key) (*Definition, error) {
|
||||||
|
ds, err := settingDefinitions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d, ok := ds[k]; ok {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
return nil, ErrNoSuchKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions returns all registered setting definitions,
|
||||||
|
// or an error if different policies were registered under the same name.
|
||||||
|
func Definitions() ([]*Definition, error) {
|
||||||
|
ds, err := settingDefinitions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res := make([]*Definition, 0, len(ds))
|
||||||
|
for _, d := range ds {
|
||||||
|
res = append(res, d)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlatformList is a list of OSes.
|
||||||
|
// An empty list indicates that all possible platforms are supported.
|
||||||
|
type PlatformList []string
|
||||||
|
|
||||||
|
// Has reports whether l contains the target platform.
|
||||||
|
func (l PlatformList) Has(target string) bool {
|
||||||
|
if len(l) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(l, func(os string) bool {
|
||||||
|
return strings.EqualFold(os, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCurrent is like Has, but for the current platform.
|
||||||
|
func (l PlatformList) HasCurrent() bool {
|
||||||
|
return l.Has(internal.OS())
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeFrom merges l2 into l. Since an empty list indicates no platform restrictions,
|
||||||
|
// if either l or l2 is empty, the merged result in l will also be empty.
|
||||||
|
func (l *PlatformList) mergeFrom(l2 PlatformList) {
|
||||||
|
switch {
|
||||||
|
case len(*l) == 0:
|
||||||
|
// No-op. An empty list indicates no platform restrictions.
|
||||||
|
case len(l2) == 0:
|
||||||
|
// Merging with an empty list results in an empty list.
|
||||||
|
*l = l2
|
||||||
|
default:
|
||||||
|
// Append, sort and dedup.
|
||||||
|
*l = append(*l, l2...)
|
||||||
|
slices.Sort(*l)
|
||||||
|
*l = slices.Compact(*l)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,344 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/types/lazy"
|
||||||
|
"tailscale.com/types/ptr"
|
||||||
|
"tailscale.com/util/syspolicy/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingDefinition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setting *Definition
|
||||||
|
osOverride string
|
||||||
|
wantKey Key
|
||||||
|
wantScope Scope
|
||||||
|
wantType Type
|
||||||
|
wantIsSupported bool
|
||||||
|
wantSupportedPlatforms PlatformList
|
||||||
|
wantString string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Nil",
|
||||||
|
setting: nil,
|
||||||
|
wantKey: "",
|
||||||
|
wantScope: 0,
|
||||||
|
wantType: InvalidValue,
|
||||||
|
wantIsSupported: false,
|
||||||
|
wantString: "(nil)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Device/Invalid",
|
||||||
|
setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, InvalidValue),
|
||||||
|
wantKey: "TestDevicePolicySetting",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: InvalidValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `Device("TestDevicePolicySetting", Invalid)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Device/Integer",
|
||||||
|
setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue),
|
||||||
|
wantKey: "TestDevicePolicySetting",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: IntegerValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `Device("TestDevicePolicySetting", Integer)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Profile/String",
|
||||||
|
setting: NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue),
|
||||||
|
wantKey: "TestProfilePolicySetting",
|
||||||
|
wantScope: ProfileSetting,
|
||||||
|
wantType: StringValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `Profile("TestProfilePolicySetting", String)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Device/StringList",
|
||||||
|
setting: NewDefinition("AllowedSuggestedExitNodes", DeviceSetting, StringListValue),
|
||||||
|
wantKey: "AllowedSuggestedExitNodes",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: StringListValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `Device("AllowedSuggestedExitNodes", StringList)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Device/PreferenceOption",
|
||||||
|
setting: NewDefinition("AdvertiseExitNode", DeviceSetting, PreferenceOptionValue),
|
||||||
|
wantKey: "AdvertiseExitNode",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: PreferenceOptionValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `Device("AdvertiseExitNode", PreferenceOption)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User/Boolean",
|
||||||
|
setting: NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue),
|
||||||
|
wantKey: "TestUserPolicySetting",
|
||||||
|
wantScope: UserSetting,
|
||||||
|
wantType: BooleanValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `User("TestUserPolicySetting", Boolean)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User/Visibility",
|
||||||
|
setting: NewDefinition("AdminConsole", UserSetting, VisibilityValue),
|
||||||
|
wantKey: "AdminConsole",
|
||||||
|
wantScope: UserSetting,
|
||||||
|
wantType: VisibilityValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `User("AdminConsole", Visibility)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User/Duration",
|
||||||
|
setting: NewDefinition("KeyExpirationNotice", UserSetting, DurationValue),
|
||||||
|
wantKey: "KeyExpirationNotice",
|
||||||
|
wantScope: UserSetting,
|
||||||
|
wantType: DurationValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantString: `User("KeyExpirationNotice", Duration)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SupportedSetting",
|
||||||
|
setting: NewDefinition("DesktopPolicySetting", DeviceSetting, StringValue, "macos", "windows"),
|
||||||
|
osOverride: "windows",
|
||||||
|
wantKey: "DesktopPolicySetting",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: StringValue,
|
||||||
|
wantIsSupported: true,
|
||||||
|
wantSupportedPlatforms: PlatformList{"macos", "windows"},
|
||||||
|
wantString: `Device("DesktopPolicySetting", String)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UnsupportedSetting",
|
||||||
|
setting: NewDefinition("AndroidPolicySetting", DeviceSetting, StringValue, "android"),
|
||||||
|
osOverride: "macos",
|
||||||
|
wantKey: "AndroidPolicySetting",
|
||||||
|
wantScope: DeviceSetting,
|
||||||
|
wantType: StringValue,
|
||||||
|
wantIsSupported: false,
|
||||||
|
wantSupportedPlatforms: PlatformList{"android"},
|
||||||
|
wantString: `Device("AndroidPolicySetting", String)`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.osOverride != "" {
|
||||||
|
internal.OSForTesting.SetForTest(t, tt.osOverride, nil)
|
||||||
|
}
|
||||||
|
if !tt.setting.Equal(tt.setting) {
|
||||||
|
t.Errorf("the setting should be equal to itself")
|
||||||
|
}
|
||||||
|
if tt.setting != nil && !tt.setting.Equal(ptr.To(*tt.setting)) {
|
||||||
|
t.Errorf("the setting should be equal to its shallow copy")
|
||||||
|
}
|
||||||
|
if gotKey := tt.setting.Key(); gotKey != tt.wantKey {
|
||||||
|
t.Errorf("Key: got %q, want %q", gotKey, tt.wantKey)
|
||||||
|
}
|
||||||
|
if gotScope := tt.setting.Scope(); gotScope != tt.wantScope {
|
||||||
|
t.Errorf("Scope: got %v, want %v", gotScope, tt.wantScope)
|
||||||
|
}
|
||||||
|
if gotType := tt.setting.Type(); gotType != tt.wantType {
|
||||||
|
t.Errorf("Type: got %v, want %v", gotType, tt.wantType)
|
||||||
|
}
|
||||||
|
if gotIsSupported := tt.setting.IsSupported(); gotIsSupported != tt.wantIsSupported {
|
||||||
|
t.Errorf("IsSupported: got %v, want %v", gotIsSupported, tt.wantIsSupported)
|
||||||
|
}
|
||||||
|
if gotSupportedPlatforms := tt.setting.SupportedPlatforms(); !slices.Equal(gotSupportedPlatforms, tt.wantSupportedPlatforms) {
|
||||||
|
t.Errorf("SupportedPlatforms: got %v, want %v", gotSupportedPlatforms, tt.wantSupportedPlatforms)
|
||||||
|
}
|
||||||
|
if gotString := tt.setting.String(); gotString != tt.wantString {
|
||||||
|
t.Errorf("String: got %v, want %v", gotString, tt.wantString)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterSettingDefinition(t *testing.T) {
|
||||||
|
const testPolicySettingKey Key = "TestPolicySetting"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key Key
|
||||||
|
wantEq *Definition
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GetRegistered",
|
||||||
|
key: "TestPolicySetting",
|
||||||
|
wantEq: NewDefinition(testPolicySettingKey, DeviceSetting, StringValue),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetNonRegistered",
|
||||||
|
key: "OtherPolicySetting",
|
||||||
|
wantEq: nil,
|
||||||
|
wantErr: ErrNoSuchKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSettingDefinitions(t)
|
||||||
|
Register(testPolicySettingKey, DeviceSetting, StringValue)
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, gotErr := DefinitionOf(tt.key)
|
||||||
|
if gotErr != tt.wantErr {
|
||||||
|
t.Errorf("gotErr %v, wantErr %v", gotErr, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !got.Equal(tt.wantEq) {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.wantEq)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterAfterUsePanics(t *testing.T) {
|
||||||
|
resetSettingDefinitions(t)
|
||||||
|
|
||||||
|
Register("TestPolicySetting", DeviceSetting, StringValue)
|
||||||
|
DefinitionOf("TestPolicySetting")
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if gotPanic, wantPanic := recover(), "policy definitions are already in use"; gotPanic != wantPanic {
|
||||||
|
t.Errorf("gotPanic: %q, wantPanic: %q", gotPanic, wantPanic)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
Register("TestPolicySetting", DeviceSetting, StringValue)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDuplicateSettings(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
settings []*Definition
|
||||||
|
wantEq *Definition
|
||||||
|
wantErrStr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "NoConflict/Exact",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
|
||||||
|
},
|
||||||
|
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NoConflict/MergeOS-First",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"),
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
|
||||||
|
},
|
||||||
|
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NoConflict/MergeOS-Second",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"),
|
||||||
|
},
|
||||||
|
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NoConflict/MergeOS-Both",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos"),
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "windows"),
|
||||||
|
},
|
||||||
|
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos", "windows"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Conflict/Scope",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
|
||||||
|
NewDefinition("TestPolicySetting", UserSetting, StringValue),
|
||||||
|
},
|
||||||
|
wantEq: nil,
|
||||||
|
wantErrStr: `duplicate policy definition: "TestPolicySetting"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Conflict/Type",
|
||||||
|
settings: []*Definition{
|
||||||
|
NewDefinition("TestPolicySetting", UserSetting, StringValue),
|
||||||
|
NewDefinition("TestPolicySetting", UserSetting, IntegerValue),
|
||||||
|
},
|
||||||
|
wantEq: nil,
|
||||||
|
wantErrStr: `duplicate policy definition: "TestPolicySetting"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resetSettingDefinitions(t)
|
||||||
|
for _, s := range tt.settings {
|
||||||
|
Register(s.Key(), s.Scope(), s.Type(), s.SupportedPlatforms()...)
|
||||||
|
}
|
||||||
|
got, err := DefinitionOf("TestPolicySetting")
|
||||||
|
var gotErrStr string
|
||||||
|
if err != nil {
|
||||||
|
gotErrStr = err.Error()
|
||||||
|
}
|
||||||
|
if gotErrStr != tt.wantErrStr {
|
||||||
|
t.Fatalf("ErrStr: got %q, want %q", gotErrStr, tt.wantErrStr)
|
||||||
|
}
|
||||||
|
if !got.Equal(tt.wantEq) {
|
||||||
|
t.Errorf("Definition got %v, want %v", got, tt.wantEq)
|
||||||
|
}
|
||||||
|
if !slices.Equal(got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms()) {
|
||||||
|
t.Errorf("SupportedPlatforms got %v, want %v", got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSettingDefinitions(t *testing.T) {
|
||||||
|
definitions := []*Definition{
|
||||||
|
NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue),
|
||||||
|
NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue),
|
||||||
|
NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue),
|
||||||
|
NewDefinition("TestStringListPolicySetting", DeviceSetting, StringListValue),
|
||||||
|
}
|
||||||
|
if err := SetDefinitionsForTest(t, definitions...); err != nil {
|
||||||
|
t.Fatalf("SetDefinitionsForTest failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmp := func(l, r *Definition) int {
|
||||||
|
return strings.Compare(string(l.Key()), string(r.Key()))
|
||||||
|
}
|
||||||
|
want := append([]*Definition{}, definitions...)
|
||||||
|
slices.SortFunc(want, cmp)
|
||||||
|
|
||||||
|
got, err := Definitions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Definitions failed: %v", err)
|
||||||
|
}
|
||||||
|
slices.SortFunc(got, cmp)
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSettingDefinitions(t *testing.T) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
definitionsMu.Lock()
|
||||||
|
definitionsList = nil
|
||||||
|
definitions = lazy.SyncValue[DefinitionMap]{}
|
||||||
|
definitionsUsed = false
|
||||||
|
definitionsMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
definitionsMu.Lock()
|
||||||
|
definitionsList = nil
|
||||||
|
definitions = lazy.SyncValue[DefinitionMap]{}
|
||||||
|
definitionsUsed = false
|
||||||
|
definitionsMu.Unlock()
|
||||||
|
}
|
@ -0,0 +1,173 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
xmaps "golang.org/x/exp/maps"
|
||||||
|
"tailscale.com/util/deephash"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot is an immutable collection of ([Key], [RawItem]) pairs, representing
|
||||||
|
// a set of policy settings applied at a specific moment in time.
|
||||||
|
// A nil pointer to [Snapshot] is valid.
|
||||||
|
type Snapshot struct {
|
||||||
|
m map[Key]RawItem
|
||||||
|
sig deephash.Sum // of m
|
||||||
|
summary Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSnapshot returns a new [Snapshot] with the specified items and options.
|
||||||
|
func NewSnapshot(items map[Key]RawItem, opts ...SummaryOption) *Snapshot {
|
||||||
|
return &Snapshot{m: xmaps.Clone(items), sig: deephash.Hash(&items), summary: SummaryWith(opts...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns a map of all policy settings in s.
|
||||||
|
// The returned map must not be modified.
|
||||||
|
func (s *Snapshot) All() map[Key]RawItem {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// TODO(nickkhyl): return iter.Seq2[[Key], [RawItem]] in Go 1.23,
|
||||||
|
// and remove [keyItemPair].
|
||||||
|
return s.m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the value of the policy setting with the specified key
|
||||||
|
// or nil if it is not configured or has an error.
|
||||||
|
func (s *Snapshot) Get(k Key) any {
|
||||||
|
v, _ := s.GetErr(k)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetErr returns the value of the policy setting with the specified key,
|
||||||
|
// [ErrNotConfigured] if it is not configured, or an error returned by
|
||||||
|
// the policy Store if the policy setting could not be read.
|
||||||
|
func (s *Snapshot) GetErr(k Key) (any, error) {
|
||||||
|
if s != nil {
|
||||||
|
if s, ok := s.m[k]; ok {
|
||||||
|
return s.Value(), s.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSetting returns the untyped policy setting with the specified key and true
|
||||||
|
// if a policy setting with such key has been configured;
|
||||||
|
// otherwise, it returns zero, false.
|
||||||
|
func (s *Snapshot) GetSetting(k Key) (setting RawItem, ok bool) {
|
||||||
|
setting, ok = s.m[k]
|
||||||
|
return setting, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal reports whether s and s2 are equal.
|
||||||
|
func (s *Snapshot) Equal(s2 *Snapshot) bool {
|
||||||
|
if !s.EqualItems(s2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.Summary() == s2.Summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualItems reports whether items in s and s2 are equal.
|
||||||
|
func (s *Snapshot) EqualItems(s2 *Snapshot) bool {
|
||||||
|
if s == s2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s.Len() != s2.Len() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.Len() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.sig == s2.sig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys return an iterator over keys in s. The iteration order is not specified
|
||||||
|
// and is not guaranteed to be the same from one call to the next.
|
||||||
|
func (s *Snapshot) Keys() []Key {
|
||||||
|
if s.m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// TODO(nickkhyl): return iter.Seq[Key] in Go 1.23.
|
||||||
|
return xmaps.Keys(s.m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len reports the number of [RawItem]s in s.
|
||||||
|
func (s *Snapshot) Len() int {
|
||||||
|
if s == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(s.m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns information about s as a whole rather than about specific [RawItem]s in it.
|
||||||
|
func (s *Snapshot) Summary() Summary {
|
||||||
|
if s == nil {
|
||||||
|
return Summary{}
|
||||||
|
}
|
||||||
|
return s.summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer]
|
||||||
|
func (s *Snapshot) String() string {
|
||||||
|
if s.Len() == 0 && s.Summary().IsEmpty() {
|
||||||
|
return "{Empty}"
|
||||||
|
}
|
||||||
|
keys := s.Keys()
|
||||||
|
slices.Sort(keys)
|
||||||
|
var sb strings.Builder
|
||||||
|
if !s.summary.IsEmpty() {
|
||||||
|
sb.WriteRune('{')
|
||||||
|
if s.Len() == 0 {
|
||||||
|
sb.WriteString("Empty, ")
|
||||||
|
}
|
||||||
|
sb.WriteString(s.summary.String())
|
||||||
|
sb.WriteRune('}')
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if sb.Len() != 0 {
|
||||||
|
sb.WriteRune('\n')
|
||||||
|
}
|
||||||
|
sb.WriteString(string(k))
|
||||||
|
sb.WriteString(" = ")
|
||||||
|
sb.WriteString(s.m[k].String())
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSnapshots returns a [Snapshot] that contains all [RawItem]s
|
||||||
|
// from snapshot1 and snapshot2 and the [Summary] with the narrower [PolicyScope].
|
||||||
|
// If there's a conflict between policy settings in the two snapshots,
|
||||||
|
// the policy settings from the snapshot with the broader scope take precedence.
|
||||||
|
// In other words, policy settings configured for the [DeviceScope] win
|
||||||
|
// over policy settings configured for a user scope.
|
||||||
|
func MergeSnapshots(snapshot1, snapshot2 *Snapshot) *Snapshot {
|
||||||
|
scope1, ok1 := snapshot1.Summary().Scope().GetOk()
|
||||||
|
scope2, ok2 := snapshot2.Summary().Scope().GetOk()
|
||||||
|
if ok1 && ok2 && scope1.StrictlyContains(scope2) {
|
||||||
|
// Swap snapshots if snapshot1 has higher precedence than snapshot2.
|
||||||
|
snapshot1, snapshot2 = snapshot2, snapshot1
|
||||||
|
}
|
||||||
|
if snapshot2.Len() == 0 {
|
||||||
|
return snapshot1
|
||||||
|
}
|
||||||
|
summaryOpts := make([]SummaryOption, 0, 2)
|
||||||
|
if scope, ok := snapshot1.Summary().Scope().GetOk(); ok {
|
||||||
|
// Use the scope from snapshot1, if present, which is the more specific snapshot.
|
||||||
|
summaryOpts = append(summaryOpts, scope)
|
||||||
|
}
|
||||||
|
if snapshot1.Len() == 0 {
|
||||||
|
if origin, ok := snapshot2.Summary().Origin().GetOk(); ok {
|
||||||
|
// Use the origin from snapshot2 if snapshot1 is empty.
|
||||||
|
summaryOpts = append(summaryOpts, origin)
|
||||||
|
}
|
||||||
|
return &Snapshot{snapshot2.m, snapshot2.sig, SummaryWith(summaryOpts...)}
|
||||||
|
}
|
||||||
|
m := make(map[Key]RawItem, snapshot1.Len()+snapshot2.Len())
|
||||||
|
xmaps.Copy(m, snapshot1.m)
|
||||||
|
xmaps.Copy(m, snapshot2.m) // snapshot2 has higher precedence
|
||||||
|
return &Snapshot{m, deephash.Hash(&m), SummaryWith(summaryOpts...)}
|
||||||
|
}
|
@ -0,0 +1,435 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeSnapshots(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s1, s2 *Snapshot
|
||||||
|
want *Snapshot
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both-nil",
|
||||||
|
s1: nil,
|
||||||
|
s2: nil,
|
||||||
|
want: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first-nil",
|
||||||
|
s1: nil,
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second-nil",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
s2: nil,
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-conflicts",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
"Setting5": {value: VisibleByPolicy},
|
||||||
|
"Setting6": {value: ShowChoiceByPolicy},
|
||||||
|
}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
"Setting5": {value: VisibleByPolicy},
|
||||||
|
"Setting6": {value: ShowChoiceByPolicy},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-conflicts",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 456},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 456},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-scope-first-wins",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}, DeviceScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 456},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-scope-second-wins",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 456},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}, DeviceScope),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 456},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-scope-both-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}, DeviceScope),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-scope-first-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true}},
|
||||||
|
DeviceScope, NewNamedOrigin("TestPolicy", DeviceScope)),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}, CurrentUserScope, NewNamedOrigin("TestPolicy", DeviceScope)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-scope-second-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
want: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := MergeSnapshots(tt.s1, tt.s2)
|
||||||
|
if !got.Equal(tt.want) {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotEqual(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s1, s2 *Snapshot
|
||||||
|
wantEqual bool
|
||||||
|
wantEqualItems bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil-nil",
|
||||||
|
s1: nil,
|
||||||
|
s2: nil,
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil-empty",
|
||||||
|
s1: nil,
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-nil",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
s2: nil,
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first-nil",
|
||||||
|
s1: nil,
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second-nil",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: true},
|
||||||
|
}),
|
||||||
|
s2: nil,
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second-empty",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{}),
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-items-same-order-no-scope",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}),
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-items-same-order-same-scope",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, DeviceScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, DeviceScope),
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-items-different-order-same-scope",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, DeviceScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting3": {value: false},
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
}, DeviceScope),
|
||||||
|
wantEqual: true,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-items-same-order-different-scope",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, DeviceScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, CurrentUserScope),
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different-items-same-scope",
|
||||||
|
s1: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 123},
|
||||||
|
"Setting2": {value: "String"},
|
||||||
|
"Setting3": {value: false},
|
||||||
|
}, DeviceScope),
|
||||||
|
s2: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting4": {value: 2 * time.Hour},
|
||||||
|
"Setting5": {value: VisibleByPolicy},
|
||||||
|
"Setting6": {value: ShowChoiceByPolicy},
|
||||||
|
}, DeviceScope),
|
||||||
|
wantEqual: false,
|
||||||
|
wantEqualItems: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if gotEqual := tt.s1.Equal(tt.s2); gotEqual != tt.wantEqual {
|
||||||
|
t.Errorf("WantEqual: got %v, want %v", gotEqual, tt.wantEqual)
|
||||||
|
}
|
||||||
|
if gotEqualItems := tt.s1.EqualItems(tt.s2); gotEqualItems != tt.wantEqualItems {
|
||||||
|
t.Errorf("WantEqualItems: got %v, want %v", gotEqualItems, tt.wantEqualItems)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
snapshot *Snapshot
|
||||||
|
wantString string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
snapshot: nil,
|
||||||
|
wantString: "{Empty}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
snapshot: NewSnapshot(nil),
|
||||||
|
wantString: "{Empty}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-with-scope",
|
||||||
|
snapshot: NewSnapshot(nil, DeviceScope),
|
||||||
|
wantString: "{Empty, Device}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-with-origin",
|
||||||
|
snapshot: NewSnapshot(nil, NewNamedOrigin("Test Policy", DeviceScope)),
|
||||||
|
wantString: "{Empty, Test Policy (Device)}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-empty",
|
||||||
|
snapshot: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 2 * time.Hour},
|
||||||
|
"Setting2": {value: VisibleByPolicy},
|
||||||
|
"Setting3": {value: ShowChoiceByPolicy},
|
||||||
|
}, NewNamedOrigin("Test Policy", DeviceScope)),
|
||||||
|
wantString: `{Test Policy (Device)}
|
||||||
|
Setting1 = 2h0m0s
|
||||||
|
Setting2 = show
|
||||||
|
Setting3 = user-decides`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-empty-with-item-origin",
|
||||||
|
snapshot: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {value: 42, origin: NewNamedOrigin("Test Policy", DeviceScope)},
|
||||||
|
}),
|
||||||
|
wantString: `Setting1 = 42 - {Test Policy (Device)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-empty-with-item-error",
|
||||||
|
snapshot: NewSnapshot(map[Key]RawItem{
|
||||||
|
"Setting1": {err: NewErrorText("bang!")},
|
||||||
|
}),
|
||||||
|
wantString: `Setting1 = Error{"bang!"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if gotString := tt.snapshot.String(); gotString != tt.wantString {
|
||||||
|
t.Errorf("got %v\nwant %v", gotString, tt.wantString)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/go-json-experiment/json/jsontext"
|
||||||
|
"tailscale.com/types/opt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Summary is an immutable [PolicyScope] and [Origin].
|
||||||
|
type Summary struct {
|
||||||
|
data summary
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Scope opt.Value[PolicyScope] `json:",omitzero"`
|
||||||
|
Origin opt.Value[Origin] `json:",omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryWith returns a [Summary] with the specified options.
|
||||||
|
func SummaryWith(opts ...SummaryOption) Summary {
|
||||||
|
var summary Summary
|
||||||
|
for _, o := range opts {
|
||||||
|
o.applySummaryOption(&summary)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty reports whether s is empty.
|
||||||
|
func (s Summary) IsEmpty() bool {
|
||||||
|
return s == Summary{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope reports the [PolicyScope] in s.
|
||||||
|
func (s Summary) Scope() opt.Value[PolicyScope] {
|
||||||
|
return s.data.Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin reports the [Origin] in s.
|
||||||
|
func (s Summary) Origin() opt.Value[Origin] {
|
||||||
|
return s.data.Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements [fmt.Stringer].
|
||||||
|
func (s Summary) String() string {
|
||||||
|
if s.IsEmpty() {
|
||||||
|
return "{Empty}"
|
||||||
|
}
|
||||||
|
if origin, ok := s.data.Origin.GetOk(); ok {
|
||||||
|
return origin.String()
|
||||||
|
}
|
||||||
|
return s.data.Scope.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||||
|
func (s Summary) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||||
|
return jsonv2.MarshalEncode(out, &s.data, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||||
|
func (s *Summary) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||||
|
return jsonv2.UnmarshalDecode(in, &s.data, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements [json.Marshaler].
|
||||||
|
func (s Summary) MarshalJSON() ([]byte, error) {
|
||||||
|
return jsonv2.Marshal(s) // uses MarshalJSONV2
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements [json.Unmarshaler].
|
||||||
|
func (s *Summary) UnmarshalJSON(b []byte) error {
|
||||||
|
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryOption is an option that configures [Summary]
|
||||||
|
// The following are allowed options:
|
||||||
|
//
|
||||||
|
// - [Summary]
|
||||||
|
// - [PolicyScope]
|
||||||
|
// - [Origin]
|
||||||
|
type SummaryOption interface {
|
||||||
|
applySummaryOption(summary *Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s PolicyScope) applySummaryOption(summary *Summary) {
|
||||||
|
summary.data.Scope.Set(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Origin) applySummaryOption(summary *Summary) {
|
||||||
|
summary.data.Origin.Set(o)
|
||||||
|
if !summary.data.Scope.IsSet() {
|
||||||
|
summary.data.Scope.Set(o.Scope())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Summary) applySummaryOption(summary *Summary) {
|
||||||
|
*summary = s
|
||||||
|
}
|
@ -0,0 +1,136 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreferenceOption is a policy that governs whether a boolean variable
|
||||||
|
// is forcibly assigned an administrator-defined value, or allowed to receive
|
||||||
|
// a user-defined value.
|
||||||
|
type PreferenceOption byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
ShowChoiceByPolicy PreferenceOption = iota
|
||||||
|
NeverByPolicy
|
||||||
|
AlwaysByPolicy
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show returns if the UI option that controls the choice administered by this
|
||||||
|
// policy should be shown. Currently this is true if and only if the policy is
|
||||||
|
// [ShowChoiceByPolicy].
|
||||||
|
func (p PreferenceOption) Show() bool {
|
||||||
|
return p == ShowChoiceByPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldEnable checks if the choice administered by this policy should be
|
||||||
|
// enabled. If the administrator has chosen a setting, the administrator's
|
||||||
|
// setting is returned, otherwise userChoice is returned.
|
||||||
|
func (p PreferenceOption) ShouldEnable(userChoice bool) bool {
|
||||||
|
switch p {
|
||||||
|
case NeverByPolicy:
|
||||||
|
return false
|
||||||
|
case AlwaysByPolicy:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return userChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAlways reports whether the preference should always be enabled.
|
||||||
|
func (p PreferenceOption) IsAlways() bool {
|
||||||
|
return p == AlwaysByPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNever reports whether the preference should always be disabled.
|
||||||
|
func (p PreferenceOption) IsNever() bool {
|
||||||
|
return p == NeverByPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// WillOverride checks if the choice administered by the policy is different
|
||||||
|
// from the user's choice.
|
||||||
|
func (p PreferenceOption) WillOverride(userChoice bool) bool {
|
||||||
|
return p.ShouldEnable(userChoice) != userChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a string representation of p.
|
||||||
|
func (p PreferenceOption) String() string {
|
||||||
|
switch p {
|
||||||
|
case AlwaysByPolicy:
|
||||||
|
return "always"
|
||||||
|
case NeverByPolicy:
|
||||||
|
return "never"
|
||||||
|
default:
|
||||||
|
return "user-decides"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextMarshaler].
|
||||||
|
func (p *PreferenceOption) MarshalText() (text []byte, err error) {
|
||||||
|
return []byte(p.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements [encoding.TextUnmarshaler].
|
||||||
|
// It never fails and sets p to [ShowChoiceByPolicy] if the specified text
|
||||||
|
// does not represent a valid [PreferenceOption].
|
||||||
|
func (p *PreferenceOption) UnmarshalText(text []byte) error {
|
||||||
|
switch string(text) {
|
||||||
|
case "always":
|
||||||
|
*p = AlwaysByPolicy
|
||||||
|
case "never":
|
||||||
|
*p = NeverByPolicy
|
||||||
|
default:
|
||||||
|
*p = ShowChoiceByPolicy
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility is a policy that controls whether or not a particular
|
||||||
|
// component of a user interface is to be shown.
|
||||||
|
type Visibility byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ encoding.TextMarshaler = (*Visibility)(nil)
|
||||||
|
_ encoding.TextUnmarshaler = (*Visibility)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
VisibleByPolicy Visibility = 'v'
|
||||||
|
HiddenByPolicy Visibility = 'h'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show reports whether the UI option administered by this policy should be shown.
|
||||||
|
// Currently this is true if the policy is not [hiddenByPolicy].
|
||||||
|
func (v Visibility) Show() bool {
|
||||||
|
return v != HiddenByPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a string representation of v.
|
||||||
|
func (v Visibility) String() string {
|
||||||
|
switch v {
|
||||||
|
case 'h':
|
||||||
|
return "hide"
|
||||||
|
default:
|
||||||
|
return "show"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements [encoding.TextMarshaler].
|
||||||
|
func (v Visibility) MarshalText() (text []byte, err error) {
|
||||||
|
return []byte(v.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements [encoding.TextUnmarshaler].
|
||||||
|
// It never fails and sets v to [VisibleByPolicy] if the specified text
|
||||||
|
// does not represent a valid [Visibility].
|
||||||
|
func (v *Visibility) UnmarshalText(text []byte) error {
|
||||||
|
switch string(text) {
|
||||||
|
case "hide":
|
||||||
|
*v = HiddenByPolicy
|
||||||
|
default:
|
||||||
|
*v = VisibleByPolicy
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in New Issue