//go:build darwin

package cli

import (
	"errors"
	"net/netip"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/Control-D-Inc/ctrld"
	"github.com/Control-D-Inc/ctrld/internal/firewall"
)

// progWithForwardedSources builds a prog whose config declares the given
// forwarded-workload source subnets. A logger is attached so the invalid-entry
// warning path is safe to exercise.
func progWithForwardedSources(sources ...string) *prog {
	p := &prog{cfg: &ctrld.Config{
		Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}},
	}}
	p.cfg.Service.FirewallForwardedSources = sources
	p.logger.Store(mainLog.Load())
	return p
}

// TestIsHypervisorVMNetIface verifies only vendor-specific VM/NAT interfaces
// qualify for auto-detection - never generic bridges (bridge*, which macOS also
// uses for Thunderbolt/aggregated links), physical uplinks, loopback, or VPN tunnels.
func TestIsHypervisorVMNetIface(t *testing.T) {
	tests := []struct {
		name string
		want bool
	}{
		{"vmnet8", true},     // VMware Fusion
		{"vmenet0", true},    // Apple Virtualization.framework / UTM
		{"vnic0", true},      // Parallels
		{"vboxnet0", true},   // VirtualBox
		{"bridge0", false},   // generic bridge (Thunderbolt/aggregated) - NOT auto-trusted
		{"bridge100", false}, // Multipass/Docker generic bridge - opt-in only
		{"en0", false},       // physical uplink
		{"lo0", false},       // loopback
		{"utun3", false},     // VPN tunnel
		{"awdl0", false},     // Apple Wireless Direct Link
	}
	for _, tt := range tests {
		if got := isHypervisorVMNetIface(tt.name); got != tt.want {
			t.Errorf("isHypervisorVMNetIface(%q) = %v, want %v", tt.name, got, tt.want)
		}
	}
}

// TestBuildPFForwardedSourceRulesFor_Basic verifies the rules emit, for each source:
// a route-to-lo0 redirect of the guest's plaintext DNS (udp+tcp) and a DoT block;
// that auto-detected sources are scoped "on <iface>" while configured sources match
// on CIDR alone; and crucially NOT an interface-wide or destination-wide permit that
// would let the guest bypass policy.
func TestBuildPFForwardedSourceRulesFor_Basic(t *testing.T) {
	sources := []forwardedSource{
		{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmnet8"}, // auto-detected
		{prefix: netip.MustParsePrefix("10.211.55.0/24")},                    // configured (no iface)
	}
	rules := buildPFForwardedSourceRulesFor(sources, "127.0.0.1")

	wants := []string{
		// Auto-detected: scoped to its ingress interface.
		"pass in quick on vmnet8 route-to lo0 inet proto udp from 192.168.105.0/24 to ! 127.0.0.1 port 53",
		"pass in quick on vmnet8 route-to lo0 inet proto tcp from 192.168.105.0/24 to ! 127.0.0.1 port 53",
		"block return in quick on vmnet8 inet proto { tcp, udp } from 192.168.105.0/24 to any port 853",
		// Configured: CIDR-only (admin opt-in), no "on <iface>".
		"pass in quick route-to lo0 inet proto udp from 10.211.55.0/24 to ! 127.0.0.1 port 53",
		"block return in quick inet proto { tcp, udp } from 10.211.55.0/24 to any port 853",
	}
	for _, w := range wants {
		if !strings.Contains(rules, w) {
			t.Errorf("missing rule:\n  %s\nin:\n%s", w, rules)
		}
	}

	// Address family must match the source literal. An "inet6 ... from <IPv4 CIDR>"
	// rule makes pfctl reject the whole anchor, which would take DNS interception
	// down with it, so no inet6 rule may name an IPv4 source.
	for _, line := range strings.Split(rules, "\n") {
		if strings.Contains(line, "inet6") && (strings.Contains(line, "192.168.105.0/24") || strings.Contains(line, "10.211.55.0/24")) {
			t.Errorf("inet6 rule with an IPv4 source - pf address-family mismatch:\n  %s", line)
		}
	}

	// Security boundary: every "pass" for a source must be the port-53 redirect;
	// no rule may grant a source an unrestricted destination.
	for _, line := range strings.Split(rules, "\n") {
		if strings.HasPrefix(line, "pass") && !strings.Contains(line, "port 53") {
			t.Errorf("forwarded-source pass rule is not scoped to DNS - possible policy bypass:\n  %s", line)
		}
		if strings.HasPrefix(line, "pass") && strings.HasSuffix(strings.TrimSpace(line), "to any") {
			t.Errorf("forwarded-source rules must not contain a blanket 'to any' permit:\n  %s", line)
		}
	}
}

// TestBuildPFForwardedSourceRulesFor_Empty verifies that with no sources the
// builder returns "", leaving anchor behavior unchanged.
func TestBuildPFForwardedSourceRulesFor_Empty(t *testing.T) {
	if got := buildPFForwardedSourceRulesFor(nil, "127.0.0.1"); got != "" {
		t.Errorf("expected empty output with no sources, got:\n%s", got)
	}
}

// TestFirewallForwardedSources_InvalidDropped verifies a malformed CIDR and a
// non-IPv4 CIDR are dropped (with a warning) without voiding the valid config
// entries, that host bits are normalized to the network address, and that configured
// sources carry no interface. Rejecting IPv6 at parse time is what keeps the emitted
// rules single-family: a mixed-family rule makes pfctl reject the whole anchor.
func TestFirewallForwardedSources_InvalidDropped(t *testing.T) {
	p := progWithForwardedSources("192.168.64.7/24", "not-a-cidr", "fd00::/64", "10.0.0.0/8")
	got := p.firewallForwardedSources()

	want := map[string]bool{"192.168.64.0/24": true, "10.0.0.0/8": true}
	if len(got) != len(want) {
		t.Fatalf("got %d sources, want %d: %v", len(got), len(want), got)
	}
	for _, src := range got {
		if !want[src.prefix.String()] {
			t.Errorf("unexpected prefix %s (invalid entries should be dropped)", src.prefix)
		}
		if src.iface != "" {
			t.Errorf("configured source %s must have no interface, got %q", src.prefix, src.iface)
		}
		if !src.prefix.Addr().Is4() {
			t.Errorf("non-IPv4 source %s must be dropped (interception is IPv4-only)", src.prefix)
		}
	}
}

// TestBuildPFForwardedSourceRulesFor_SkipsNonIPv4 verifies an IPv6 source produces no
// rules even if one reaches the builder, so a stray entry can never introduce a
// mixed-family rule that pfctl would reject the whole anchor over.
func TestBuildPFForwardedSourceRulesFor_SkipsNonIPv4(t *testing.T) {
	v6 := forwardedSource{prefix: netip.MustParsePrefix("fd00::/64")}
	if got := buildPFForwardedSourceRulesFor([]forwardedSource{v6}, "127.0.0.1"); got != "" {
		t.Errorf("IPv6-only source must produce no rules, got:\n%s", got)
	}

	v4 := forwardedSource{prefix: netip.MustParsePrefix("192.168.64.0/24"), iface: "vmnet8"}
	rules := buildPFForwardedSourceRulesFor([]forwardedSource{v6, v4}, "127.0.0.1")
	if strings.Contains(rules, "fd00::") {
		t.Errorf("IPv6 source must be skipped in a mixed set:\n%s", rules)
	}
	if !strings.Contains(rules, "from 192.168.64.0/24 to ! 127.0.0.1 port 53") {
		t.Errorf("IPv4 source must still produce its rules:\n%s", rules)
	}
	for _, line := range strings.Split(rules, "\n") {
		if strings.HasPrefix(line, "pass") || strings.HasPrefix(line, "block") {
			if strings.Contains(line, "inet6") {
				t.Errorf("no inet6 rule may be emitted for IPv4-only sources:\n  %s", line)
			}
		}
	}
}

// TestPFForwardedSourceRules_Syntax runs the real pf parser over the generated rules.
// This is the check string assertions cannot make: pfctl rejects an ENTIRE ruleset
// over one malformed or mixed-address-family rule, so a bad forwarded-source rule
// would take DNS interception down with it rather than just failing to trust a guest.
//
// Two rulesets are parsed: the forwarded-source rules alone (self-contained, so this
// arm is environment-independent) and the full anchor ctrld would load.
func TestPFForwardedSourceRules_Syntax(t *testing.T) {
	// lo0 as the auto-detected ingress interface: any interface name parses, and lo0
	// is the one guaranteed to exist on every runner.
	sources := []forwardedSource{
		{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "lo0"}, // auto-detected, scoped
		{prefix: netip.MustParsePrefix("10.211.55.0/24")},                 // configured, CIDR-only
	}

	t.Run("forwarded rules alone", func(t *testing.T) {
		rules := buildPFForwardedSourceRulesFor(sources, "127.0.0.1")
		if rules == "" {
			t.Fatal("no forwarded-source rules generated")
		}
		pfctlParseCheck(t, rules)
	})

	t.Run("full anchor", func(t *testing.T) {
		p := progWithForwardedSources("192.168.64.0/24", "10.211.55.0/24")
		p.allowList = firewall.New()
		rules := p.buildPFAnchorRules(nil)
		if !strings.Contains(rules, "from 192.168.64.0/24 to ! ") {
			t.Fatalf("forwarded-source rules missing from anchor under test:\n%s", rules)
		}
		pfctlParseCheck(t, stripPFGroupRules(rules))
	})
}

// pfctlParseCheck validates a ruleset with the real pf parser in ctrld's anchor
// context, failing the test on any parse error. pfctl needs /dev/pf, so the check
// skips (rather than fails) where the runner cannot open it.
func pfctlParseCheck(t *testing.T, ruleset string) {
	t.Helper()

	pfctl, err := exec.LookPath("pfctl")
	if err != nil {
		t.Skip("pfctl not available:", err)
	}
	file := filepath.Join(t.TempDir(), "ctrld-rules-test.conf")
	if err := os.WriteFile(file, []byte(ruleset), 0600); err != nil {
		t.Fatalf("write ruleset under test: %v", err)
	}

	// -n parses and validates without loading anything.
	out, err := exec.Command(pfctl, "-a", pfAnchorName, "-n", "-f", file).CombinedOutput()
	if err == nil {
		return
	}
	msg := strings.TrimSpace(string(out))
	if strings.Contains(msg, "Permission denied") || strings.Contains(msg, "Operation not permitted") ||
		strings.Contains(msg, "/dev/pf") {
		t.Skipf("pfctl cannot open /dev/pf on this runner (%v): %s", err, msg)
	}
	t.Errorf("pfctl rejected the generated ruleset (%v):\n%s\n--- ruleset ---\n%s", err, msg, ruleset)
}

// stripPFGroupRules drops rules scoped to ctrld's runtime group. That group is created
// by the installed service (dscl), so on a dev box or CI runner pfctl reports "unknown
// group _ctrld" for them - an environment fact, not a defect in the generated rules.
// Only those lines are removed, so pf's ordering requirement (translation rules before
// filtering rules) still holds for what remains.
func stripPFGroupRules(ruleset string) string {
	lines := strings.Split(ruleset, "\n")
	kept := make([]string, 0, len(lines))
	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "group "+pfGroupName) {
			continue
		}
		kept = append(kept, line)
	}
	return strings.Join(kept, "\n")
}

// TestForwardedSources_UnionDedup verifies the effective set unions auto-detected
// and configured subnets, de-duplicated by prefix. Auto-detection is
// environment-dependent, so this asserts config entries are always included and
// that duplicate config entries collapse to one - deterministic regardless of host.
func TestForwardedSources_UnionDedup(t *testing.T) {
	p := progWithForwardedSources("192.168.199.0/24", "192.168.199.0/24")
	got := p.forwardedSources()

	count := 0
	for _, src := range got {
		if src.prefix.String() == "192.168.199.0/24" {
			count++
		}
	}
	if count != 1 {
		t.Errorf("configured subnet appears %d times, want exactly 1 (union must dedup):\n%v", count, got)
	}
}

// TestForwardedSourceDescriptions verifies the log rendering names each subnet's
// origin, including the interface an auto-detected source is scoped to. Configured
// entries used to be invisible in the log, which left admins unable to confirm
// firewall_forwarded_sources took effect.
func TestForwardedSourceDescriptions(t *testing.T) {
	got := forwardedSourceDescriptions([]forwardedSource{
		{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmenet0"},
		{prefix: netip.MustParsePrefix("192.168.252.0/24")},
	})
	want := []string{
		"192.168.105.0/24 (auto-detected on vmenet0)",
		"192.168.252.0/24 (configured)",
	}
	if !equalStringSets(got, want) {
		t.Errorf("descriptions = %v, want %v", got, want)
	}

	if got := forwardedSourceDescriptions(nil); len(got) != 0 {
		t.Errorf("empty set must render no descriptions, got %v", got)
	}
}

// TestForwardedSourceSetKey verifies the signature is order-independent, distinguishes
// interface scope, and changes when the set changes - the basis for detecting VM
// start/stop at runtime.
func TestForwardedSourceSetKey(t *testing.T) {
	a := netip.MustParsePrefix("192.168.64.0/24")
	b := netip.MustParsePrefix("10.211.55.0/24")

	// Order-independent.
	k1 := forwardedSourceSetKey([]forwardedSource{{prefix: a, iface: "vmnet8"}, {prefix: b}})
	k2 := forwardedSourceSetKey([]forwardedSource{{prefix: b}, {prefix: a, iface: "vmnet8"}})
	if k1 != k2 {
		t.Errorf("key must be order-independent: %q vs %q", k1, k2)
	}

	// A guest appearing changes the key (empty -> one source).
	if forwardedSourceSetKey(nil) == k1 {
		t.Error("adding a source must change the key")
	}

	// A guest stopping changes the key (two sources -> one).
	k3 := forwardedSourceSetKey([]forwardedSource{{prefix: b}})
	if k3 == k1 {
		t.Error("removing a source must change the key")
	}

	// Same prefix on a different interface is a distinct trust and must differ.
	kIface := forwardedSourceSetKey([]forwardedSource{{prefix: a, iface: "vmnet8"}})
	kNoIface := forwardedSourceSetKey([]forwardedSource{{prefix: a}})
	if kIface == kNoIface {
		t.Error("interface scope must affect the key")
	}
}

// TestApplyForwardedSourceChange_Lifecycle walks the guest start/stop lifecycle
// deterministically (no pf, no hypervisor): initial build, guest start, no-change
// re-check, second guest start, guest stop, scope change, and back to none. Each
// anchor reload succeeds here. It asserts both halves of the contract - whether the
// anchor needs rebuilding, and which subnets' pf states must be dropped because
// their trust changed.
func TestApplyForwardedSourceChange_Lifecycle(t *testing.T) {
	vmA := forwardedSource{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmnet8"}
	vmB := forwardedSource{prefix: netip.MustParsePrefix("10.211.55.0/24"), iface: "vnic0"}
	cfgB := forwardedSource{prefix: vmB.prefix} // same subnet, configured (no iface scope)

	steps := []struct {
		name        string
		cur         []forwardedSource
		wantChanged bool
		wantGained  []string
		wantLost    []string
	}{
		{name: "initial state, no guests", cur: nil, wantChanged: false},
		{
			name: "first guest starts", cur: []forwardedSource{vmA},
			wantChanged: true, wantGained: []string{"192.168.105.0/24"},
		},
		{name: "network change, nothing moved", cur: []forwardedSource{vmA}, wantChanged: false},
		{
			// Reordered plus a new guest: order must not register as a change.
			name: "second guest starts", cur: []forwardedSource{vmB, vmA},
			wantChanged: true, wantGained: []string{"10.211.55.0/24"},
		},
		{
			name: "first guest stops", cur: []forwardedSource{vmB},
			wantChanged: true, wantLost: []string{"192.168.105.0/24"},
		},
		{
			// Same subnet, different scope: a distinct trust, so its states must be
			// dropped even though the subnet itself neither appeared nor vanished.
			name: "guest subnet loses its interface scope", cur: []forwardedSource{cfgB},
			wantChanged: true,
			wantGained:  []string{"10.211.55.0/24"},
			wantLost:    []string{"10.211.55.0/24"},
		},
		{
			name: "last guest stops", cur: nil,
			wantChanged: true, wantLost: []string{"10.211.55.0/24"},
		},
		{name: "still no guests", cur: nil, wantChanged: false},
	}

	state := &pfFirewallState{}
	reloads := 0
	okReload := func() error { reloads++; return nil }
	wantReloads := 0
	for _, step := range steps {
		gained, lost, changed, err := state.applyForwardedSourceChange(step.cur, okReload)
		if err != nil {
			t.Fatalf("%s: unexpected reload error: %v", step.name, err)
		}
		if changed != step.wantChanged {
			t.Errorf("%s: changed = %v, want %v", step.name, changed, step.wantChanged)
		}
		if got := prefixStrings(gained); !equalStringSets(got, step.wantGained) {
			t.Errorf("%s: gained trust = %v, want %v", step.name, got, step.wantGained)
		}
		if got := prefixStrings(lost); !equalStringSets(got, step.wantLost) {
			t.Errorf("%s: lost trust = %v, want %v", step.name, got, step.wantLost)
		}
		// The anchor must be rebuilt exactly on the transitions, never on a re-check.
		if step.wantChanged {
			wantReloads++
		}
		if reloads != wantReloads {
			t.Errorf("%s: anchor reloads = %d, want %d", step.name, reloads, wantReloads)
		}
	}
}

// TestApplyForwardedSourceChange_FailureThenRetry is the convergence guarantee: a
// failed anchor write/load must NOT advance the applied snapshot, so the very next
// reconcile (at the latest the next watchdog tick) retries the same transition
// instead of seeing the new key and going quiet with the old anchor still installed.
func TestApplyForwardedSourceChange_FailureThenRetry(t *testing.T) {
	guest := netip.MustParsePrefix("192.168.105.0/24")
	cur := []forwardedSource{{prefix: guest, iface: "vmnet8"}}

	state := &pfFirewallState{}
	loadErr := errors.New("pfctl: syntax error")
	attempts := 0
	failing := func() error { attempts++; return loadErr }
	succeeding := func() error { attempts++; return nil }

	// Attempt 1: guest starts, reload fails. The caller is told what changed (so it
	// can log it) but must not treat it as applied.
	gained, lost, changed, err := state.applyForwardedSourceChange(cur, failing)
	if !changed || !errors.Is(err, loadErr) {
		t.Fatalf("failed reload: changed = %v, err = %v, want true / the load error", changed, err)
	}
	if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
		t.Errorf("failed reload: gained trust = %v, want %v", got, []string{guest.String()})
	}
	if len(lost) != 0 {
		t.Errorf("failed reload: lost trust = %v, want none", prefixStrings(lost))
	}
	if state.lastForwardedKey != "" || state.lastForwardedSources != nil {
		t.Fatalf("failed reload must not advance the applied snapshot, got key %q sources %v",
			state.lastForwardedKey, state.lastForwardedSources)
	}

	// Attempt 2: nothing else moved, but the change is still pending - it must be
	// retried and reported identically, not swallowed.
	gained, _, changed, err = state.applyForwardedSourceChange(cur, failing)
	if !changed || err == nil {
		t.Fatalf("retry after failure: changed = %v, err = %v, want true / an error", changed, err)
	}
	if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
		t.Errorf("retry after failure: gained trust = %v, want %v", got, []string{guest.String()})
	}

	// Attempt 3: pf accepts the anchor - now the snapshot advances and the affected
	// subnet's states are reported for killing.
	gained, _, changed, err = state.applyForwardedSourceChange(cur, succeeding)
	if !changed || err != nil {
		t.Fatalf("successful reload: changed = %v, err = %v, want true / nil", changed, err)
	}
	if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
		t.Errorf("successful reload: gained trust = %v, want %v", got, []string{guest.String()})
	}
	if state.lastForwardedKey == "" {
		t.Fatal("successful reload must record the applied source set")
	}

	// Attempt 4: converged - no further rebuild, and no reload call at all.
	before := attempts
	if _, _, changed, err := state.applyForwardedSourceChange(cur, succeeding); changed || err != nil {
		t.Errorf("after convergence: changed = %v, err = %v, want false / nil", changed, err)
	}
	if attempts != before {
		t.Errorf("after convergence: reload was called %d extra time(s), want 0", attempts-before)
	}

	// A failure while *removing* trust must likewise not be latched: the subnet stays
	// recorded as applied until pf accepts the anchor without it.
	if _, lost, changed, err := state.applyForwardedSourceChange(nil, failing); !changed || err == nil {
		t.Errorf("guest stop with failing reload: changed = %v, err = %v, want true / an error", changed, err)
	} else if got := prefixStrings(lost); !equalStringSets(got, []string{guest.String()}) {
		t.Errorf("guest stop with failing reload: lost trust = %v, want %v", got, []string{guest.String()})
	}
	if state.lastForwardedKey == "" {
		t.Error("failed removal must keep the previously applied set recorded")
	}
	if _, lost, _, err := state.applyForwardedSourceChange(nil, succeeding); err != nil {
		t.Errorf("guest stop retry: unexpected error %v", err)
	} else if got := prefixStrings(lost); !equalStringSets(got, []string{guest.String()}) {
		t.Errorf("guest stop retry: lost trust = %v, want %v", got, []string{guest.String()})
	}
	if state.lastForwardedKey != "" || state.lastForwardedSources != nil {
		t.Errorf("after successful removal the applied set must be empty, got key %q sources %v",
			state.lastForwardedKey, state.lastForwardedSources)
	}
}

// TestReconcileForwardedSources_GatedOff verifies the reconcile entry point is inert
// when firewall mode is off or pf state was never initialized, so the network-change
// and watchdog call sites never touch pf outside firewall mode.
func TestReconcileForwardedSources_GatedOff(t *testing.T) {
	// Firewall mode off (no allowList) - must return before touching pf state.
	off := progWithForwardedSources("192.168.64.0/24")
	off.platformFirewallState = &pfFirewallState{}
	off.dnsInterceptState = &pfState{anchorFile: pfAnchorFile, anchorName: pfAnchorName}
	off.reconcileForwardedSources()
	if state := off.platformFirewallState.(*pfFirewallState); state.lastForwardedKey != "" {
		t.Errorf("reconcile must not record a source set when firewall mode is off, got %q", state.lastForwardedKey)
	}

	// Firewall mode on but pf firewall state not initialized - must not panic.
	noState := progWithForwardedSources("192.168.64.0/24")
	noState.allowList = firewall.New()
	noState.dnsInterceptState = &pfState{anchorFile: pfAnchorFile, anchorName: pfAnchorName}
	noState.reconcileForwardedSources()

	// Firewall mode on but intercept inactive - no anchor to rebuild.
	noIntercept := progWithForwardedSources("192.168.64.0/24")
	noIntercept.allowList = firewall.New()
	noIntercept.platformFirewallState = &pfFirewallState{}
	noIntercept.reconcileForwardedSources()
	if state := noIntercept.platformFirewallState.(*pfFirewallState); state.lastForwardedKey != "" {
		t.Errorf("reconcile must not record a source set without intercept, got %q", state.lastForwardedKey)
	}
}

// equalStringSets compares two string slices ignoring order and nil-vs-empty.
func equalStringSets(got, want []string) bool {
	if len(got) != len(want) {
		return false
	}
	seen := make(map[string]int, len(got))
	for _, s := range got {
		seen[s]++
	}
	for _, s := range want {
		seen[s]--
		if seen[s] < 0 {
			return false
		}
	}
	return true
}

// TestDetectForwardedSources_OnlyPrivate verifies detection returns only private
// IPv4 vendor-VM subnets, each tagged with a vendor interface. Environment-dependent,
// so it asserts a property rather than an exact set.
func TestDetectForwardedSources_OnlyPrivate(t *testing.T) {
	p := &prog{cfg: &ctrld.Config{}}
	p.logger.Store(mainLog.Load())
	for _, src := range p.detectForwardedSources() {
		if !src.prefix.Addr().Is4() {
			t.Errorf("detected non-IPv4 forwarded source: %s", src.prefix)
		}
		if !src.prefix.Addr().IsPrivate() {
			t.Errorf("detected non-private forwarded source (must never auto-trust public): %s", src.prefix)
		}
		if !isHypervisorVMNetIface(src.iface) {
			t.Errorf("detected source on non-vendor interface %q", src.iface)
		}
	}
}

// TestPFBuildAnchorRules_ForwardedSourcesGating verifies the forwarded-source rules
// appear in the full anchor only when firewall mode is active, and when a configured
// source is present it appears before the blanket allowlist block so the redirect
// wins. A configured source makes the "on" case deterministic regardless of host.
func TestPFBuildAnchorRules_ForwardedSourcesGating(t *testing.T) {
	// Firewall OFF (no allowList): a configured source must NOT appear.
	off := progWithForwardedSources("192.168.64.0/24")
	if rules := off.buildPFAnchorRules(nil); strings.Contains(rules, "192.168.64.0/24 to ! ") {
		t.Errorf("forwarded-source rules must not be emitted when firewall mode is off:\n%s", rules)
	}

	// Firewall ON: allowList present → rules appear, before the blanket block.
	on := progWithForwardedSources("192.168.64.0/24")
	on.allowList = firewall.New()
	rules := on.buildPFAnchorRules(nil)

	fwdIdx := strings.Index(rules, "from 192.168.64.0/24 to ! 127.0.0.1 port 53")
	blockIdx := strings.Index(rules, "block return out quick inet proto { tcp, udp } from any to any")
	if fwdIdx < 0 {
		t.Fatalf("configured forwarded-source redirect missing when firewall mode is on:\n%s", rules)
	}
	if blockIdx < 0 {
		t.Fatalf("blanket firewall block missing:\n%s", rules)
	}
	if fwdIdx >= blockIdx {
		t.Errorf("forwarded-source redirect (%d) must come before the blanket block (%d)", fwdIdx, blockIdx)
	}
}
