testcapture: add typed capture format package

Typed Capture/Input/Node/Topology structs for golden SaaS captures.
Schema drift between the tscap capture tool and headscale now becomes a
compile error instead of a silent test pass.

Updates #3157
This commit is contained in:
Kristoffer Dalby
2026-04-15 08:28:25 +00:00
parent 1059c678c4
commit f34dec2754
5 changed files with 1122 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
package testcapture
import (
"fmt"
"strings"
)
// CommentHeader returns the // comment header that gets prepended to
// a Capture file when it is written. The header is purely
// informational; consumers ignore it. Format:
//
// <TestID>
//
// <Description, possibly multi-line>
//
// Nodes with filter rules: <X> of <Y> ← for non-SSH corpora
// Nodes with SSH rules: <X> of <Y> ← for SSH corpora
// Captured at: <RFC3339 UTC>
// tscap version: <ToolVersion>
// schema version: <SchemaVersion>
//
// Both `tool_version` and `schema_version` are also stored as
// first-class JSON fields on the Capture struct; the comment lines
// exist purely so the values are visible at a glance without
// parsing the file.
//
// The leading "// " on every line is added by the hujson writer.
func CommentHeader(c *Capture) string {
if c == nil {
return ""
}
var b strings.Builder
b.WriteString(c.TestID)
b.WriteByte('\n')
if c.Description != "" {
b.WriteByte('\n')
b.WriteString(c.Description)
b.WriteByte('\n')
}
stats := captureStats(c)
if stats != "" {
b.WriteByte('\n')
b.WriteString(stats)
b.WriteByte('\n')
}
if !c.CapturedAt.IsZero() {
fmt.Fprintf(&b, "Captured at: %s\n", c.CapturedAt.UTC().Format("2006-01-02T15:04:05Z"))
}
if c.ToolVersion != "" {
fmt.Fprintf(&b, "tscap version: %s\n", c.ToolVersion)
}
if c.SchemaVersion != 0 {
fmt.Fprintf(&b, "schema version: %d\n", c.SchemaVersion)
}
return strings.TrimRight(b.String(), "\n")
}
// captureStats returns a one-line summary of how many nodes had
// non-empty captured data, or the empty string if there are no
// captures at all.
//
// The phrasing depends on which fields the corpus uses:
// - SSH corpora populate SSHRules
// - other corpora populate PacketFilterRules
//
// If both fields appear (mixed/unusual), filter rules wins.
func captureStats(c *Capture) string {
if len(c.Captures) == 0 {
return ""
}
var (
total = len(c.Captures)
filterRules int
sshRules int
filterRulesSet bool
sshRulesSet bool
)
for _, n := range c.Captures {
if n.PacketFilterRules != nil {
filterRulesSet = true
if len(n.PacketFilterRules) > 0 {
filterRules++
}
}
if n.SSHRules != nil {
sshRulesSet = true
if len(n.SSHRules) > 0 {
sshRules++
}
}
}
switch {
case filterRulesSet:
return fmt.Sprintf("Nodes with filter rules: %d of %d", filterRules, total)
case sshRulesSet:
return fmt.Sprintf("Nodes with SSH rules: %d of %d", sshRules, total)
default:
return ""
}
}

View File

@@ -0,0 +1,62 @@
package testcapture
import (
"encoding/json"
"errors"
"fmt"
"os"
"github.com/tailscale/hujson"
)
// ErrUnsupportedSchemaVersion is returned by Read when a capture
// advertises a SchemaVersion newer than the current binary supports.
var ErrUnsupportedSchemaVersion = errors.New("testcapture: unsupported schema version")
// Read parses a HuJSON capture file from disk into a Capture.
//
// Comments and trailing commas in the file are stripped before
// unmarshaling. Files advertising a SchemaVersion newer than the
// current binary's are rejected with ErrUnsupportedSchemaVersion;
// SchemaVersion == 0 (pre-versioning) is accepted for backwards compat.
// The returned Capture's CapturedAt is the value recorded in the file
// (not "now").
func Read(path string) (*Capture, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("testcapture: read %s: %w", path, err)
}
var c Capture
err = unmarshalHuJSON(data, &c)
if err != nil {
return nil, fmt.Errorf("testcapture: %s: %w", path, err)
}
if c.SchemaVersion > SchemaVersion {
return nil, fmt.Errorf("%w: %s has version %d, binary supports %d",
ErrUnsupportedSchemaVersion, path, c.SchemaVersion, SchemaVersion)
}
return &c, nil
}
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
// commas) into v. Comments are stripped via hujson.Standardize before
// json.Unmarshal is called.
func unmarshalHuJSON(data []byte, v any) error {
ast, err := hujson.Parse(data)
if err != nil {
return fmt.Errorf("hujson parse: %w", err)
}
ast.Standardize()
err = json.Unmarshal(ast.Pack(), v)
if err != nil {
return fmt.Errorf("json unmarshal: %w", err)
}
return nil
}

View File

@@ -0,0 +1,325 @@
// Package testcapture defines the on-disk format used by Headscale's
// policy v2 compatibility tests for golden data captured from
// Tailscale SaaS by the tscap tool.
//
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
// apitype values rather than json.RawMessage so that schema drift
// between tscap and headscale becomes a compile error rather than a
// silent test failure, and so that consumers don't have to repeat
// json.Unmarshal at every read site. Storing data as json.RawMessage
// previously hid a serious capture-pipeline bug (the IPN bus initial
// notification returns a stale Peers slice — see the comment on
// Node.Netmap below) for months.
//
// All four corpora (acl, routes, grant, ssh) use the same Capture
// shape. SSH scenarios populate Captures[name].SSHRules; the others
// populate Captures[name].PacketFilterRules + Captures[name].Netmap.
package testcapture
import (
"bytes"
"encoding/json"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/filter/filtertype"
)
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
//
// Files written before SchemaVersion existed do not have this field; new
// captures from tscap always set it to the current value.
const SchemaVersion = 1
// Capture is one captured run of one scenario.
//
// All four corpora (acl, routes, grant, ssh) use this same shape.
// SSH scenarios populate Captures[name].SSHRules; the others populate
// Captures[name].PacketFilterRules + Captures[name].Netmap.
type Capture struct {
// SchemaVersion identifies the on-disk format version. Always set
// to testcapture.SchemaVersion when written by tscap.
SchemaVersion int `json:"schema_version"`
// TestID is the stable identifier of the scenario, derived from
// its filename. Used as the test name in Go tests.
TestID string `json:"test_id"`
// Description is free-form text copied from the scenario file.
// Rendered in the comment header at the top of the file.
Description string `json:"description,omitempty"`
// Category is an optional grouping label (e.g. "routes",
// "grant", "ssh").
Category string `json:"category,omitempty"`
// CapturedAt is the UTC timestamp of when the capture was taken.
CapturedAt time.Time `json:"captured_at"`
// ToolVersion identifies the binary that produced the file.
ToolVersion string `json:"tool_version"`
// Tailnet is the name of the SaaS tailnet the capture was taken
// against (e.g. "kratail2tid@passkey").
Tailnet string `json:"tailnet"`
// Error is true when the SaaS API rejected the policy or when
// capture itself failed. In the rejection case, Captures reflects
// the pre-push baseline (deny-all default) and Input.APIResponseBody
// is populated.
Error bool `json:"error,omitempty"`
// CaptureError is set when the capture itself failed (timeout,
// missing data, etc.). The partially-captured Captures map is
// still included for post-mortem. Distinct from
// Input.APIResponseBody which describes a SaaS API rejection.
CaptureError string `json:"capture_error,omitempty"`
// Input is everything that was sent to the tailnet to produce
// the captured state.
Input Input `json:"input"`
// Topology is the users and nodes present in the tailnet at
// capture time. Always populated by tscap.
Topology Topology `json:"topology"`
// Captures holds the per-node captured data, keyed by node
// GivenName.
Captures map[string]Node `json:"captures"`
}
// Input describes everything that was sent to the tailnet to produce
// the captured state.
//
// Input has a custom UnmarshalJSON to accept both the new on-disk
// shape (where full_policy is a JSON-encoded string) and the legacy
// shape (where full_policy is a JSON object). The legacy shape is
// re-marshaled to a string at load time so consumers see the typed
// field uniformly.
type Input struct {
// FullPolicy is the verbatim policy that was POSTed to the SaaS
// API. Stored as a string because it is opaque JSON that round-
// trips losslessly without parsing — headscale's policy parser
// reads it on demand.
FullPolicy string `json:"full_policy"`
// APIResponseCode is the HTTP status code of the policy POST.
APIResponseCode int `json:"api_response_code"`
// APIResponseBody is only populated when APIResponseCode != 200.
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
// Tailnet describes the tailnet-wide settings tscap applied
// before pushing the policy.
Tailnet TailnetInput `json:"tailnet"`
// ScenarioHuJSON is the verbatim contents of the scenario file
// (HuJSON). Reading this back is enough to re-run the exact
// same scenario.
ScenarioHuJSON string `json:"scenario_hujson"`
// ScenarioPath is the path the scenario was loaded from,
// relative to the captures directory. Informational only.
ScenarioPath string `json:"scenario_path,omitempty"`
}
// MarshalJSON writes FullPolicy as a raw JSON object rather than a
// double-quoted string. Consumers (including via_compat_test.go which
// uses its own local types) expect to parse full_policy as a JSON
// object, not a JSON string. The UnmarshalJSON below accepts both
// forms on read so old and new captures are interchangeable.
func (i Input) MarshalJSON() ([]byte, error) {
type alias Input
raw := struct {
alias
FullPolicy json.RawMessage `json:"full_policy"`
}{
alias: alias(i),
}
if i.FullPolicy != "" {
raw.FullPolicy = json.RawMessage(i.FullPolicy)
}
return json.Marshal(raw)
}
// UnmarshalJSON handles both the current on-disk shape (full_policy
// as a JSON-encoded string) and the legacy shape (full_policy as a
// JSON object). Legacy objects are re-marshaled into a string at
// load time so consumers see the typed field uniformly. New captures
// always write the object form via the custom MarshalJSON above.
func (i *Input) UnmarshalJSON(data []byte) error {
type alias Input
var raw struct {
alias
FullPolicy json.RawMessage `json:"full_policy"`
}
err := json.Unmarshal(data, &raw)
if err != nil {
return err
}
*i = Input(raw.alias)
// raw.FullPolicy might be a JSON-encoded string ("...") or a JSON
// object/array/null. Try string first; on failure use the raw bytes
// verbatim, normalised to compact form.
if len(raw.FullPolicy) == 0 || string(raw.FullPolicy) == "null" {
i.FullPolicy = ""
return nil
}
if raw.FullPolicy[0] == '"' {
var s string
err := json.Unmarshal(raw.FullPolicy, &s)
if err != nil {
return err
}
i.FullPolicy = s
return nil
}
// Legacy (and new MarshalJSON output): full_policy is a raw JSON
// object. Compact whitespace but preserve key ordering so the
// round-trip is stable.
var buf bytes.Buffer
err = json.Compact(&buf, raw.FullPolicy)
if err != nil {
return err
}
i.FullPolicy = buf.String()
return nil
}
// APIResponseBody is the (subset of) the SaaS API error response we keep.
type APIResponseBody struct {
Message string `json:"message,omitempty"`
}
// TailnetInput captures tailnet-wide settings tscap applied before
// pushing the policy.
type TailnetInput struct {
DNS DNSInput `json:"dns"`
Settings SettingsInput `json:"settings"`
}
// DNSInput describes the DNS configuration applied to the tailnet.
type DNSInput struct {
MagicDNS bool `json:"magic_dns"`
Nameservers []string `json:"nameservers"`
SearchPaths []string `json:"search_paths"`
SplitDNS map[string][]string `json:"split_dns"`
}
// SettingsInput describes tailnet settings applied via the API.
//
// Pointer fields are nil when the scenario does not override the
// reset default for that setting.
type SettingsInput struct {
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
}
// Topology describes the users and nodes present in the tailnet at
// capture time. Headscale's compat tests use this to construct
// equivalent types.User and types.Node objects.
type Topology struct {
// Users in the tailnet. Always populated by tscap.
Users []TopologyUser `json:"users"`
// Nodes in the tailnet, keyed by GivenName.
Nodes map[string]TopologyNode `json:"nodes"`
}
// TopologyUser identifies one user account in the tailnet.
type TopologyUser struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// TopologyNode is one node in the tailnet topology.
type TopologyNode struct {
Hostname string `json:"hostname"`
Tags []string `json:"tags"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
// User is the TopologyUser.Name for user-owned nodes. Empty for
// tagged nodes.
User string `json:"user,omitempty"`
// RoutableIPs is what the node advertised
// (Hostinfo.RoutableIPs in its own netmap.SelfNode).
// May include 0.0.0.0/0 + ::/0 for exit nodes.
RoutableIPs []string `json:"routable_ips"`
// ApprovedRoutes is the subset of RoutableIPs the tailnet has
// approved. Used by Headscale's NodeCanApproveRoute test.
ApprovedRoutes []string `json:"approved_routes"`
}
// Node is the captured state for one node, keyed by GivenName in
// Capture.Captures.
//
// All four corpora populate the same struct. Different fields are
// used by different test types:
//
// - acl, routes, grant: PacketFilterRules + PacketFilterMatches + Netmap
// - grant (with capture_whois): + Whois
// - ssh: SSHRules
//
// Whichever fields are set in the file is what the consumer reads.
type Node struct {
// PacketFilterRules is the wire-format filter rules as returned
// by tailscaled localapi /debug-packet-filter-rules. The single
// most important field for ACL/routes/grant tests.
PacketFilterRules []tailcfg.FilterRule `json:"packet_filter_rules,omitempty"`
// PacketFilterMatches is the compiled filter matches (with
// CapMatch) returned by tailscaled localapi
// /debug-packet-filter-matches. Captured alongside
// PacketFilterRules; useful for grant tests that want the
// compiled form.
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
// Netmap is the full netmap as observed by the local tailscaled.
// NEVER trimmed. Consumers extract whatever fields they need.
//
// IMPORTANT: tscap captures this by waiting for the IPN bus to
// settle on a fresh delta-triggered notification, NOT by reading
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
// The initial notification carries cn.NetMap() which returns
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
// set at full-sync time and never re-synchronized from the
// authoritative nb.peers map. tscap previously used the initial
// notification and silently captured netmaps with mostly-empty
// Peers, which corrupted every via-grant compat test against the
// stale data. See tscap/tsdaemon/capture.go:NetMap for the
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
// same reason.
Netmap *netmap.NetworkMap `json:"netmap,omitempty"`
// Whois is per-peer whois lookups, keyed by peer IP. Captured
// only when scenario.options.capture_whois is true.
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
// SSHRules is the SSH rules slice extracted from
// netmap.SSHPolicy.Rules. Populated only for SSH scenarios.
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
}

View File

@@ -0,0 +1,484 @@
package testcapture_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
func sampleACLCapture() *testcapture.Capture {
rules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
},
}
nm := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{Name: "user1.tail.example.com."}).View(),
}
return &testcapture.Capture{
SchemaVersion: testcapture.SchemaVersion,
TestID: "ACL-A01",
Description: "wildcard ACL: every node sees every other node",
Category: "acl",
CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
APIResponseCode: 200,
Tailnet: testcapture.TailnetInput{
DNS: testcapture.DNSInput{
MagicDNS: false,
Nameservers: []string{},
SearchPaths: []string{},
SplitDNS: map[string][]string{},
},
},
ScenarioHuJSON: `{"id":"acl-a01","policy":{}}`,
ScenarioPath: "scenarios/acl/acl-a01.hujson",
},
Topology: testcapture.Topology{
Users: []testcapture.TopologyUser{
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
{ID: 2, Name: "kristoffer", Email: "kristoffer@dalby.cc"},
},
Nodes: map[string]testcapture.TopologyNode{
"user1": {
Hostname: "user1",
IPv4: "100.90.199.68",
IPv6: "fd7a:115c:a1e0::2d01:c747",
User: "kratail2tid",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
"tagged-server": {
Hostname: "tagged-server",
Tags: []string{"tag:server"},
IPv4: "100.108.74.26",
IPv6: "fd7a:115c:a1e0::b901:4a87",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
},
},
Captures: map[string]testcapture.Node{
"user1": {
PacketFilterRules: rules,
Netmap: nm,
},
"tagged-server": {
PacketFilterRules: rules,
Netmap: nm,
},
},
}
}
func sampleSSHCapture() *testcapture.Capture {
sshRules := []*tailcfg.SSHRule{
{
Action: &tailcfg.SSHAction{Accept: true},
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.90.199.68"}},
SSHUsers: map[string]string{"root": "root"},
},
}
return &testcapture.Capture{
SchemaVersion: testcapture.SchemaVersion,
TestID: "SSH-A01",
Description: "ssh accept autogroup:member to autogroup:self",
Category: "ssh",
CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
APIResponseCode: 200,
Tailnet: testcapture.TailnetInput{
DNS: testcapture.DNSInput{
Nameservers: []string{},
SearchPaths: []string{},
SplitDNS: map[string][]string{},
},
},
ScenarioHuJSON: `{"id":"ssh-a01"}`,
},
Topology: testcapture.Topology{
Users: []testcapture.TopologyUser{
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
},
Nodes: map[string]testcapture.TopologyNode{
"user1": {
Hostname: "user1",
IPv4: "100.90.199.68",
IPv6: "fd7a:115c:a1e0::2d01:c747",
User: "kratail2tid",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
},
},
Captures: map[string]testcapture.Node{
"user1": {SSHRules: sshRules},
},
}
}
// equalViaJSON compares two captures by JSON-marshaling them and
// comparing the bytes. The Capture struct embeds tailcfg view types
// with unexported pointer fields that go-cmp can't traverse, so a
// JSON round-trip is the simplest way to verify Write+Read produced
// equivalent values.
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
t.Helper()
wantJSON, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal want: %v", err)
}
gotJSON, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("marshal got: %v", err)
}
if string(wantJSON) != string(gotJSON) {
t.Errorf("roundtrip mismatch\n--- want ---\n%s\n--- got ---\n%s",
string(wantJSON), string(gotJSON))
}
}
func TestWriteReadRoundtrip_ACL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ACL-A01.hujson")
in := sampleACLCapture()
err := testcapture.Write(path, in)
if err != nil {
t.Fatalf("Write: %v", err)
}
out, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
equalViaJSON(t, in, out)
}
func TestWriteReadRoundtrip_SSH(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SSH-A01.hujson")
in := sampleSSHCapture()
err := testcapture.Write(path, in)
if err != nil {
t.Fatalf("Write: %v", err)
}
out, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
equalViaJSON(t, in, out)
}
func TestWrite_ProducesCommentHeader(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ACL-A01.hujson")
c := sampleACLCapture()
err := testcapture.Write(path, c)
if err != nil {
t.Fatalf("Write: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(string(raw), "\n")
if len(lines) == 0 {
t.Fatal("file is empty")
}
// First line should be the test ID prefixed with "// ".
if want := "// ACL-A01"; lines[0] != want {
t.Errorf("first line: want %q, got %q", want, lines[0])
}
header := strings.Join(extractHeaderLines(lines), "\n")
if !strings.Contains(header, "wildcard ACL") {
t.Errorf("header missing description; got:\n%s", header)
}
if !strings.Contains(header, "Nodes with filter rules: 2 of 2") {
t.Errorf("header missing stats line; got:\n%s", header)
}
if !strings.Contains(header, "Captured at:") || !strings.Contains(header, "2026-04-07T12:34:56Z") {
t.Errorf("header missing capture timestamp; got:\n%s", header)
}
if !strings.Contains(header, "tscap version:") || !strings.Contains(header, "tscap-test-0.0.0") {
t.Errorf("header missing tool version; got:\n%s", header)
}
if !strings.Contains(header, "schema version: 1") {
t.Errorf("header missing schema version; got:\n%s", header)
}
}
func TestWrite_SSH_StatsUseSSHRules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SSH-A01.hujson")
c := sampleSSHCapture()
err := testcapture.Write(path, c)
if err != nil {
t.Fatalf("Write: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
header := strings.Join(extractHeaderLines(strings.Split(string(raw), "\n")), "\n")
if !strings.Contains(header, "Nodes with SSH rules: 1 of 1") {
t.Errorf("ssh stats line missing; got:\n%s", header)
}
}
func TestRead_HuJSONWithComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manual.hujson")
const content = `// hand-written
// comments + trailing commas
{
"schema_version": 1,
"test_id": "MANUAL",
"captured_at": "2026-04-07T12:00:00Z",
"tool_version": "test",
"tailnet": "example.com",
"input": {
"full_policy": "{}",
"api_response_code": 200,
"tailnet": {
"dns": {
"magic_dns": false,
"nameservers": [],
"search_paths": [],
"split_dns": {},
},
"settings": {},
},
"scenario_hujson": "",
},
"topology": {
"users": [],
"nodes": {},
},
"captures": {},
}
`
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
c, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
if c.TestID != "MANUAL" {
t.Errorf("TestID = %q, want MANUAL", c.TestID)
}
if c.SchemaVersion != testcapture.SchemaVersion {
t.Errorf("SchemaVersion = %d, want %d", c.SchemaVersion, testcapture.SchemaVersion)
}
}
func TestRead_RejectsNewerSchemaVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "future.hujson")
content := fmt.Sprintf(`{
"schema_version": %d,
"test_id": "FUTURE",
"captured_at": "2099-01-01T00:00:00Z",
"tool_version": "future",
"tailnet": "example.com",
"input": {
"full_policy": "{}",
"api_response_code": 200,
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": ""
},
"topology": {"users": [], "nodes": {}},
"captures": {}
}`, testcapture.SchemaVersion+1)
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err = testcapture.Read(path)
if !errors.Is(err, testcapture.ErrUnsupportedSchemaVersion) {
t.Fatalf("Read(future-schema) = %v, want ErrUnsupportedSchemaVersion", err)
}
}
func TestWrite_NilCapture(t *testing.T) {
err := testcapture.Write(filepath.Join(t.TempDir(), "x.hujson"), nil)
if err == nil {
t.Fatal("Write(nil) returned nil error, want error")
}
}
func TestCommentHeader_NilSafe(t *testing.T) {
if got := testcapture.CommentHeader(nil); got != "" {
t.Errorf("CommentHeader(nil) = %q, want empty", got)
}
}
func TestCommentHeader_ZeroTime(t *testing.T) {
c := &testcapture.Capture{TestID: "ZERO"}
got := testcapture.CommentHeader(c)
if strings.Contains(got, "Captured at") {
t.Errorf("zero time should not produce 'Captured at': %q", got)
}
if !strings.HasPrefix(got, "ZERO") {
t.Errorf("header should start with TestID: %q", got)
}
}
func TestCommentHeader_NoStatsForEmptyCaptures(t *testing.T) {
c := &testcapture.Capture{TestID: "EMPTY"}
header := testcapture.CommentHeader(c)
if strings.Contains(header, "filter rules") || strings.Contains(header, "SSH rules") {
t.Errorf("empty captures should not produce stats line: %q", header)
}
}
func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
// Mixed: nil, empty slice, and one populated rule. Only the
// populated entry should be counted in the "filter rules" stat.
c := &testcapture.Capture{
TestID: "NULLS",
Captures: map[string]testcapture.Node{
"a": {PacketFilterRules: nil},
"b": {PacketFilterRules: []tailcfg.FilterRule{}},
"c": {PacketFilterRules: []tailcfg.FilterRule{{SrcIPs: []string{"*"}}}},
},
}
header := testcapture.CommentHeader(c)
// Only "b" and "c" are non-nil, so the corpus is detected as
// "filter rules" — and only "c" actually has rules. With the new
// typed semantics, b's empty slice still counts as "set" (not
// nil), so the denominator is 2 of 3 capture entries that have
// any filter-rules slice at all, and 1 of those is populated.
if !strings.Contains(header, "Nodes with filter rules: 1 of 3") {
t.Errorf("expected '1 of 3' in header; got:\n%s", header)
}
}
// TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture
// file written with full_policy as a raw JSON object (not a
// JSON-encoded string) still deserialises into a valid Input, with
// the policy re-marshaled to a compact string so downstream consumers
// see a uniform typed field.
func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
legacy := []byte(`{
"full_policy": {"tagOwners": {"tag:ops": ["user@example.com"]}},
"api_response_code": 200,
"tailnet": {"name": "corp", "dnsConfig": {}},
"scenario_hujson": "",
"scenario_path": ""
}`)
var got testcapture.Input
err := json.Unmarshal(legacy, &got)
if err != nil {
t.Fatalf("legacy unmarshal: %v", err)
}
if got.APIResponseCode != 200 {
t.Errorf("APIResponseCode: got %d, want 200", got.APIResponseCode)
}
want := `{"tagOwners":{"tag:ops":["user@example.com"]}}`
if got.FullPolicy != want {
t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
}
// Round-trip: the new MarshalJSON must emit the object form so
// UnmarshalJSON re-reads it identically.
out, err := json.Marshal(got)
if err != nil {
t.Fatalf("re-marshal: %v", err)
}
var back testcapture.Input
err = json.Unmarshal(out, &back)
if err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if back.FullPolicy != want {
t.Errorf("round-trip FullPolicy drift:\n got %q\nwant %q", back.FullPolicy, want)
}
}
// extractHeaderLines returns the leading "// ..." comment lines from a
// slice of raw file lines, stripped of the "// " prefix. Stops at the
// first non-comment line.
func extractHeaderLines(lines []string) []string {
var out []string
for _, l := range lines {
switch {
case strings.HasPrefix(l, "// "):
out = append(out, strings.TrimPrefix(l, "// "))
case l == "//":
out = append(out, "")
default:
return out
}
}
return out
}

View File

@@ -0,0 +1,137 @@
package testcapture
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/tailscale/hujson"
)
// ErrNilCapture is returned by Write when called with a nil Capture.
var ErrNilCapture = errors.New("testcapture: nil capture")
// Write serializes c as a HuJSON file with a comment header. The
// write is atomic: body lands in a temp file in the target directory
// and is then renamed into place, so concurrent regeneration cannot
// leave a half-written file behind.
//
// The comment header is built by CommentHeader from c's TestID,
// Description, and Captures. The file's parent directory must
// already exist; callers should MkdirAll first.
func Write(path string, c *Capture) error {
if c == nil {
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
}
header := CommentHeader(c)
body, err := marshalHuJSON(c)
if err != nil {
return fmt.Errorf("testcapture: marshal %s: %w", path, err)
}
data := prependCommentHeader(body, header)
dir := filepath.Dir(path)
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, base+".*.tmp")
if err != nil {
return fmt.Errorf("testcapture: tempfile %s: %w", path, err)
}
tmpName := tmp.Name()
cleanup := func() {
_ = os.Remove(tmpName)
}
_, err = tmp.Write(data)
if err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("testcapture: write %s: %w", path, err)
}
err = tmp.Chmod(0o600)
if err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("testcapture: chmod %s: %w", path, err)
}
err = tmp.Close()
if err != nil {
cleanup()
return fmt.Errorf("testcapture: close %s: %w", path, err)
}
err = os.Rename(tmpName, path)
if err != nil {
cleanup()
return fmt.Errorf("testcapture: rename %s: %w", path, err)
}
return nil
}
// marshalHuJSON serializes v as HuJSON-formatted bytes. It is
// standard JSON encoding followed by hujson.Format which produces
// consistent indentation/whitespace.
func marshalHuJSON(v any) ([]byte, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("json marshal: %w", err)
}
formatted, err := hujson.Format(raw)
if err != nil {
return nil, fmt.Errorf("hujson format: %w", err)
}
return formatted, nil
}
// prependCommentHeader emits header as // comment lines at the top of
// body. Empty lines in header become "//" alone (no trailing space).
// The returned bytes always end with a single trailing newline.
func prependCommentHeader(body []byte, header string) []byte {
if header == "" {
if len(body) == 0 || body[len(body)-1] != '\n' {
body = append(body, '\n')
}
return body
}
var buf strings.Builder
for line := range strings.SplitSeq(header, "\n") {
if line == "" {
buf.WriteString("//\n")
continue
}
buf.WriteString("// ")
buf.WriteString(line)
buf.WriteByte('\n')
}
buf.Write(body)
if !strings.HasSuffix(buf.String(), "\n") {
buf.WriteByte('\n')
}
return []byte(buf.String())
}