diff --git a/hscontrol/types/testcapture/header.go b/hscontrol/types/testcapture/header.go new file mode 100644 index 00000000..ffc63fc1 --- /dev/null +++ b/hscontrol/types/testcapture/header.go @@ -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: +// +// +// +// +// +// Nodes with filter rules: of ← for non-SSH corpora +// Nodes with SSH rules: of ← for SSH corpora +// Captured at: +// tscap version: +// schema version: +// +// 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 "" + } +} diff --git a/hscontrol/types/testcapture/read.go b/hscontrol/types/testcapture/read.go new file mode 100644 index 00000000..03ec4809 --- /dev/null +++ b/hscontrol/types/testcapture/read.go @@ -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 +} diff --git a/hscontrol/types/testcapture/testcapture.go b/hscontrol/types/testcapture/testcapture.go new file mode 100644 index 00000000..1ff69ef4 --- /dev/null +++ b/hscontrol/types/testcapture/testcapture.go @@ -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"` +} diff --git a/hscontrol/types/testcapture/testcapture_test.go b/hscontrol/types/testcapture/testcapture_test.go new file mode 100644 index 00000000..69a17787 --- /dev/null +++ b/hscontrol/types/testcapture/testcapture_test.go @@ -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 +} diff --git a/hscontrol/types/testcapture/write.go b/hscontrol/types/testcapture/write.go new file mode 100644 index 00000000..f6d21a57 --- /dev/null +++ b/hscontrol/types/testcapture/write.go @@ -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()) +}