Files
tailscale/types/prefs/prefs.go
T
Brad FitzpatrickandBrad Fitzpatrick 82cfea90ca all: fix JSON serialization under Go 1.27's finalized encoding/json/v2
Go 1.27 enables GOEXPERIMENT=jsonv2 by default: encoding/json is now
backed by the json/v2 machinery, and github.com/go-json-experiment/json
compiles as a thin alias of the standard library's encoding/json/v2.
Several tag options and behaviors we relied on did not make the cut for
the final Go 1.27 API, breaking tailscaled at runtime and four packages'
tests. This change adapts to the final API while keeping the wire format
byte-for-byte identical on all Go versions.

First, the `format` tag option was demoted to experimental. Its mere
presence in a struct tag now makes marshaling and unmarshaling fail at
runtime. tailcfg.SSHAction.SessionDuration had `format:nano` (added in
a2dc517d7 to pin the v1 representation), so on Go 1.27 any netmap
containing an SSH policy failed to decode, breaking every PollNetMap.
Remove the option here and in net/speedtest; time.Duration still
marshals as int64 nanoseconds under encoding/json on all Go versions
(Go 1.27's v1 mode sets FormatDurationAsNano by default), so old
clients and servers are unaffected. Add a regression test locking in
the exact wire format.

Consequently, invert the cmd/vet jsontags rule: it previously required
an explicit `format` tag on time.Duration fields, which is now exactly
wrong. It now rejects any `format` tag option, which would have caught
this bug in CI.

Second, the `inline` tag option was renamed to `embed`. The standard
library silently ignores `inline`, while the pinned go-json-experiment
module (used on Go 1.26) only knows `inline`. Specify both options in
types/prefs and logtail; each implementation ignores the option it does
not know, producing identical output. Drop `inline` once we require
Go 1.27.

Third, encoding/json (v1) now dispatches to MarshalJSONTo and
UnmarshalJSONFrom methods and its v1 options flow into nested
jsonv2.MarshalEncode calls. Types whose v1 methods deliberately
routed through jsonv2 for v2 semantics (types/opt.Value, the
types/prefs preference types) would silently change wire format
(e.g. nil slices becoming null). Pin jsonv2.DefaultOptionsV2 in
their jsonv2 methods so the representation is the same regardless
of the entry point.

Finally, json.Marshal costs one more allocation under Go 1.27,
tripping the types/logger.AsJSON alloc test. Switch its fmt.Formatter
to jsonv2.MarshalWrite with explicit v1 options, which writes directly
to the fmt.State: one allocation on both toolchains with unchanged
output. Depaware files pick up the go-json-experiment/json/v1 options
shim as a new dependency of types/logger.

With this change, go test ./... passes with both Go 1.26.5 and
go1.27rc2.

Updates #20220
Fixes #20528
Fixes #20254

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I694c7d57fd81e55a579c579e9be10032bca569d4
2026-07-19 09:58:50 -07:00

199 lines
6.7 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package prefs contains types and functions to work with arbitrary
// preference hierarchies.
//
// Specifically, the package provides [Item], [List], [Map], [StructList] and [StructMap]
// types which represent individual preferences in a user-defined prefs struct.
// A valid prefs struct must contain one or more exported fields of the preference types,
// either directly or within nested structs, but not pointers to these types.
// Additionally to preferences, a prefs struct may contain any number of
// non-preference fields that will be marshalled and unmarshalled but are
// otherwise ignored by the prefs package.
//
// The preference types are compatible with the [tailscale.com/cmd/viewer] and
// [tailscale.com/cmd/cloner] utilities. It is recommended to generate a read-only view
// of the user-defined prefs structure and use it in place of prefs whenever the prefs
// should not be modified.
package prefs
import (
"errors"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/types/opt"
)
var (
// ErrManaged is the error returned when attempting to modify a managed preference.
ErrManaged = errors.New("cannot modify a managed preference")
// ErrReadOnly is the error returned when attempting to modify a read-only preference.
ErrReadOnly = errors.New("cannot modify a read-only preference")
)
// metadata holds type-agnostic preference metadata.
type metadata struct {
// Managed indicates whether the preference is managed via MDM, Group Policy, or other means.
Managed bool `json:",omitzero"`
// ReadOnly indicates whether the preference is read-only due to any other reasons,
// such as user's access rights.
ReadOnly bool `json:",omitzero"`
}
// serializable is a JSON-serializable preference data.
type serializable[T any] struct {
// Value is an optional preference value that is set when the preference is
// configured by the user or managed by an admin.
Value opt.Value[T] `json:",omitzero"`
// Default is the default preference value to be used
// when the preference has not been configured.
Default T `json:",omitzero"`
// Metadata is any additional type-agnostic preference metadata to be serialized.
//
// The `inline` tag option was renamed to `embed` in Go 1.27's
// encoding/json/v2, but the pinned go-json-experiment module
// (used on older Go versions) only knows `inline`. Each
// implementation ignores the option it doesn't know, so specify
// both until we require Go 1.27 and drop `inline`.
//
//lint:ignore SA5008 staticcheck doesn't know Go 1.27's `embed` option yet
Metadata metadata `json:",inline,embed"`
}
// preference is an embeddable type that provides a common implementation for
// concrete preference types, such as [Item], [List], [Map], [StructList] and [StructMap].
type preference[T any] struct {
s serializable[T]
}
// preferenceOf returns a preference with the specified value and/or [Options].
func preferenceOf[T any](v opt.Value[T], opts ...Options) preference[T] {
var m metadata
for _, o := range opts {
o(&m)
}
return preference[T]{serializable[T]{Value: v, Metadata: m}}
}
// IsSet reports whether p has a value set.
func (p preference[T]) IsSet() bool {
return p.s.Value.IsSet()
}
// Value returns the value of p if the preference has a value set.
// Otherwise, it returns its default value.
func (p preference[T]) Value() T {
val, _ := p.ValueOk()
return val
}
// ValueOk returns the value of p and true if the preference has a value set.
// Otherwise, it returns its default value and false.
func (p preference[T]) ValueOk() (val T, ok bool) {
if val, ok = p.s.Value.GetOk(); ok {
return val, true
}
return p.DefaultValue(), false
}
// SetValue configures the preference with the specified value.
// It fails and returns [ErrManaged] if p is a managed preference,
// and [ErrReadOnly] if p is a read-only preference.
func (p *preference[T]) SetValue(val T) error {
switch {
case p.s.Metadata.Managed:
return ErrManaged
case p.s.Metadata.ReadOnly:
return ErrReadOnly
default:
p.s.Value.Set(val)
return nil
}
}
// ClearValue resets the preference to an unconfigured state.
// It fails and returns [ErrManaged] if p is a managed preference,
// and [ErrReadOnly] if p is a read-only preference.
func (p *preference[T]) ClearValue() error {
switch {
case p.s.Metadata.Managed:
return ErrManaged
case p.s.Metadata.ReadOnly:
return ErrReadOnly
default:
p.s.Value.Clear()
return nil
}
}
// DefaultValue returns the default value of p.
func (p preference[T]) DefaultValue() T {
return p.s.Default
}
// SetDefaultValue sets the default value of p.
func (p *preference[T]) SetDefaultValue(def T) {
p.s.Default = def
}
// IsManaged reports whether p is managed via MDM, Group Policy, or similar means.
func (p preference[T]) IsManaged() bool {
return p.s.Metadata.Managed
}
// SetManagedValue configures the preference with the specified value
// and marks the preference as managed.
func (p *preference[T]) SetManagedValue(val T) {
p.s.Value.Set(val)
p.s.Metadata.Managed = true
}
// ClearManaged clears the managed flag of the preference without altering its value.
func (p *preference[T]) ClearManaged() {
p.s.Metadata.Managed = false
}
// IsReadOnly reports whether p is read-only and cannot be changed by user.
func (p preference[T]) IsReadOnly() bool {
return p.s.Metadata.ReadOnly || p.s.Metadata.Managed
}
// SetReadOnly sets the read-only status of p, preventing changes by a user if set to true.
func (p *preference[T]) SetReadOnly(readonly bool) {
p.s.Metadata.ReadOnly = readonly
}
var (
_ jsonv2.MarshalerTo = (*preference[struct{}])(nil)
_ jsonv2.UnmarshalerFrom = (*preference[struct{}])(nil)
)
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (p preference[T]) MarshalJSONTo(out *jsontext.Encoder) error {
// Pin v2 semantics so the wire format (notably the handling of
// the `inline` and `omitzero` tag options in [serializable])
// stays the same even when this method is reached via
// encoding/json v1, which as of Go 1.27 dispatches here with v1
// options that would otherwise leak into this call.
return jsonv2.MarshalEncode(out, &p.s, jsonv2.DefaultOptionsV2())
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (p *preference[T]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
// Pin v2 semantics; see MarshalJSONTo.
return jsonv2.UnmarshalDecode(in, &p.s, jsonv2.DefaultOptionsV2())
}
// MarshalJSON implements [json.Marshaler].
func (p preference[T]) MarshalJSON() ([]byte, error) {
return jsonv2.Marshal(p) // uses MarshalJSONTo
}
// UnmarshalJSON implements [json.Unmarshaler].
func (p *preference[T]) UnmarshalJSON(b []byte) error {
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONFrom
}