diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 5d5e1db..303c1d4 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -151,6 +151,25 @@ func isMobile() bool { return runtime.GOOS == "android" || runtime.GOOS == "ios" } +func updateConfigInterceptMode(cfg *ctrld.Config, mode string) bool { + desired := "" + switch mode { + case "dns", "hard": + desired = mode + case "off": + desired = "" + case "": + return false + default: + return false + } + if cfg.Service.InterceptMode == desired { + return false + } + cfg.Service.InterceptMode = desired + return true +} + // isAndroid reports whether the current OS is Android. func isAndroid() bool { return runtime.GOOS == "android" @@ -367,14 +386,12 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { processLogAndCacheFlags(v, &cfg) } - // Persist intercept_mode to config when provided via CLI flag on full install. - // This ensures the config file reflects the actual running mode for RMM/MDM visibility. - if interceptMode == "dns" || interceptMode == "hard" { - if cfg.Service.InterceptMode != interceptMode { - cfg.Service.InterceptMode = interceptMode - updated = true - p.Info().Msgf("writing intercept_mode = %q to config", interceptMode) - } + // Keep config and the explicit CLI/service mode in sync. In particular, "off" + // must clear a previously persisted dns/hard value or the next service start + // would silently re-enable interception from config. + if updateConfigInterceptMode(&cfg, interceptMode) { + updated = true + p.Info().Msgf("writing intercept_mode = %q to config", cfg.Service.InterceptMode) } // Persist firewall_mode to config only when provided via CLI flag. diff --git a/cmd/cli/commands_service_start.go b/cmd/cli/commands_service_start.go index d4f9588..40b5d10 100644 --- a/cmd/cli/commands_service_start.go +++ b/cmd/cli/commands_service_start.go @@ -118,21 +118,23 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error { svcExists := serviceConfigFileExists() logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode) if interceptOnly && svcExists { - // Remove any existing intercept flags before applying the new value. - _ = removeServiceFlag("--intercept-mode") + // Replace any existing split or --intercept-mode= form. Keep an + // explicit "off" argument so it overrides a previously persisted config + // value while the service clears that value on startup. + if err := removeServiceFlag("--intercept-mode"); err != nil { + logger.Fatal().Err(err).Msg("failed to remove existing intercept mode from service arguments") + } if interceptMode == "off" { - // "off" = remove intercept mode entirely (just the removal above). - logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments") + logger.Notice().Msg("Existing service detected — disabling intercept mode") } else { - // Add the new mode value. logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode) - if err := appendServiceFlag("--intercept-mode"); err != nil { - logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments") - } - if err := appendServiceFlag(interceptMode); err != nil { - logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments") - } + } + if err := appendServiceFlag("--intercept-mode"); err != nil { + logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments") + } + if err := appendServiceFlag(interceptMode); err != nil { + logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments") } // Stop the service if running (bypasses ctrld pin — this is an diff --git a/cmd/cli/intercept_mode_config_test.go b/cmd/cli/intercept_mode_config_test.go new file mode 100644 index 0000000..a3167eb --- /dev/null +++ b/cmd/cli/intercept_mode_config_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "testing" + + "github.com/Control-D-Inc/ctrld" +) + +func TestUpdateConfigInterceptMode(t *testing.T) { + tests := []struct { + name string + current string + mode string + want string + wantUpdated bool + }{ + {name: "empty flag preserves config", current: "dns", mode: "", want: "dns"}, + {name: "dns is persisted", mode: "dns", want: "dns", wantUpdated: true}, + {name: "hard is persisted", current: "dns", mode: "hard", want: "hard", wantUpdated: true}, + {name: "off clears persisted mode", current: "dns", mode: "off", want: "", wantUpdated: true}, + {name: "off is idempotent", mode: "off", want: ""}, + {name: "invalid flag preserves config", current: "hard", mode: "invalid", want: "hard"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &ctrld.Config{} + cfg.Service.InterceptMode = tc.current + updated := updateConfigInterceptMode(cfg, tc.mode) + if updated != tc.wantUpdated { + t.Fatalf("updateConfigInterceptMode() updated = %v, want %v", updated, tc.wantUpdated) + } + if cfg.Service.InterceptMode != tc.want { + t.Fatalf("service.intercept_mode = %q, want %q", cfg.Service.InterceptMode, tc.want) + } + }) + } +} diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go index 5b4a082..d9b7cef 100644 --- a/cmd/cli/main_test.go +++ b/cmd/cli/main_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "strings" + "sync" "testing" "go.uber.org/zap" @@ -13,7 +14,28 @@ import ( "github.com/Control-D-Inc/ctrld" ) -var logOutput strings.Builder +// logOutput is the log sink for the whole test binary. Tests share it with any +// background goroutine the code under test starts (watchdogs, timers), so it +// must tolerate concurrent writes. +var logOutput syncBuffer + +// syncBuffer is a strings.Builder guarded by a mutex. +type syncBuffer struct { + mu sync.Mutex + sb strings.Builder +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.sb.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.sb.String() +} // envFakeVersionOutput makes this test binary impersonate a ctrld executable: when // set, the process writes the value to stdout and exits without running any test, so diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 0255df8..ed99d30 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -923,11 +923,12 @@ func (p *prog) setDNS() { // Validate and resolve intercept mode. // CLI flag (--intercept-mode) takes priority over config file. - // Valid values: "" (off), "dns" (with VPN split routing), "hard" (all DNS through ctrld). + // Valid values: "" (use config), "off" (explicitly disable), "dns" (with VPN + // split routing), and "hard" (all DNS through ctrld). if interceptMode != "" && !validInterceptMode(interceptMode) { p.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode) } - if interceptMode == "" || interceptMode == "off" { + if interceptMode == "" { interceptMode = p.configuredInterceptMode() if interceptMode != "" && interceptMode != "off" { p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", interceptMode) diff --git a/cmd/cli/prog_intercept_fallback_test.go b/cmd/cli/prog_intercept_fallback_test.go index 5a0a9b6..06592d8 100644 --- a/cmd/cli/prog_intercept_fallback_test.go +++ b/cmd/cli/prog_intercept_fallback_test.go @@ -132,6 +132,22 @@ func (h *interceptFallbackHarness) run(t *testing.T) { p.setDNS() } +func TestSetDNSExplicitOffOverridesConfig(t *testing.T) { + h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53}) + interceptMode = "off" + dnsIntercept = false + hardIntercept = false + + h.run(t) + + if h.interceptCalls != 0 { + t.Fatalf("intercept start called %d time(s), want 0: explicit off must override service.intercept_mode", h.interceptCalls) + } + if h.installCalls != 1 { + t.Fatalf("interface DNS installed %d time(s), want 1", h.installCalls) + } +} + // TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it // drives the real setDNS() lifecycle rather than the classification helper alone. // diff --git a/cmd/cli/service.go b/cmd/cli/service.go index 63ac5f4..e769dc7 100644 --- a/cmd/cli/service.go +++ b/cmd/cli/service.go @@ -124,6 +124,11 @@ func (s *systemd) Start() error { // This is necessary for running self-upgrade flow. func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) { opts, err := unit.DeserializeOptions(r) + // staticcheck sees only the explicit non-nil sends on the lexer's error + // channel, so it reports this comparison as always true. On success the + // lexer sends nothing and closes the channel, so the receive yields a nil + // error and this branch is not taken. + //lint:ignore SA4023 upstream delivers a nil error by closing the channel if err != nil { mainLog.Load().Error().Err(err).Msg("Failed to deserialize options") return diff --git a/cmd/cli/service_args_darwin.go b/cmd/cli/service_args_darwin.go index d588960..5bc1823 100644 --- a/cmd/cli/service_args_darwin.go +++ b/cmd/cli/service_args_darwin.go @@ -24,19 +24,19 @@ func serviceConfigFileExists() bool { // to intercept mode without losing the existing --cd flag and other arguments. // // On macOS, this modifies the launchd plist at /Library/LaunchDaemons/ctrld.plist -// using the "defaults" command, which is the standard way to edit plists. +// using PlistBuddy for exact array reads and writes. // // The function is idempotent: if the flag already exists, it's a no-op. func appendServiceFlag(flag string) error { // Read current ProgramArguments from plist. - out, err := exec.Command("defaults", "read", launchdPlistPath, "ProgramArguments").CombinedOutput() + out, err := exec.Command("/usr/libexec/PlistBuddy", "-c", "Print :ProgramArguments", launchdPlistPath).CombinedOutput() if err != nil { return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out))) } - // Check if the flag is already present (idempotent). - args := string(out) - if strings.Contains(args, flag) { + // Check exact array entries. A substring match can confuse a mode such as "off" + // with an unrelated path or argument and leave the flag without its value. + if serviceArgumentPresent(out, flag) { mainLog.Load().Debug().Msgf("Service flag %q already present in plist, skipping", flag) return nil } @@ -61,9 +61,8 @@ func verifyServiceRegistration() error { return nil } -// removeServiceFlag removes a CLI flag (and its value, if the next argument is not -// a flag) from the installed service's launch arguments. For example, removing -// "--intercept-mode" also removes the following "dns" or "hard" value argument. +// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the +// installed service's launch arguments. // // The function is idempotent: if the flag doesn't exist, it's a no-op. func removeServiceFlag(flag string) error { @@ -92,22 +91,14 @@ func removeServiceFlag(flag string) error { entries = append(entries, trimmed) } - index := -1 - for i, entry := range entries { - if entry == flag { - index = i - break - } - } + index, hasValue := serviceFlagPosition(entries, flag) if index < 0 { mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag) return nil } - // Check if the next entry is a value (not a flag). If so, delete it first - // (deleting by index shifts subsequent entries down, so delete value before flag). - hasValue := index+1 < len(entries) && !strings.HasPrefix(entries[index+1], "-") + // Delete a separate value first. An inline --flag=value entry is one array item. if hasValue { delVal := exec.Command( "/usr/libexec/PlistBuddy", @@ -132,3 +123,24 @@ func removeServiceFlag(flag string) error { mainLog.Load().Info().Msgf("Removed %q from service launch arguments", flag) return nil } + +func serviceArgumentPresent(out []byte, argument string) bool { + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == argument { + return true + } + } + return false +} + +func serviceFlagPosition(entries []string, flag string) (index int, hasValue bool) { + for i, entry := range entries { + switch { + case entry == flag: + return i, i+1 < len(entries) && !strings.HasPrefix(entries[i+1], "-") + case strings.HasPrefix(entry, flag+"="): + return i, false + } + } + return -1, false +} diff --git a/cmd/cli/service_args_darwin_test.go b/cmd/cli/service_args_darwin_test.go new file mode 100644 index 0000000..dc23b3b --- /dev/null +++ b/cmd/cli/service_args_darwin_test.go @@ -0,0 +1,58 @@ +//go:build darwin + +package cli + +import "testing" + +func TestServiceArgumentPresent(t *testing.T) { + out := []byte("Array {\n /usr/local/bin/ctrld\n run\n --config=/Users/officer/ctrld.toml\n --intercept-mode=dns\n}\n") + if !serviceArgumentPresent(out, "--intercept-mode=dns") { + t.Fatal("exact inline argument was not found") + } + if serviceArgumentPresent(out, "--intercept-mode") { + t.Fatal("inline flag was mistaken for a separate flag argument") + } + if serviceArgumentPresent(out, "off") { + t.Fatal("substring in an unrelated path was mistaken for the off argument") + } +} + +func TestServiceFlagPosition(t *testing.T) { + tests := []struct { + name string + entries []string + wantIndex int + wantHasValue bool + }{ + { + name: "split form", + entries: []string{"run", "--cd=uid", "--intercept-mode", "dns"}, + wantIndex: 2, + wantHasValue: true, + }, + { + name: "inline form", + entries: []string{"run", "--cd=uid", "--intercept-mode=dns"}, + wantIndex: 2, + }, + { + name: "flag followed by another flag", + entries: []string{"run", "--intercept-mode", "--config=/etc/ctrld.toml"}, + wantIndex: 1, + }, + { + name: "absent", + entries: []string{"run", "--cd=uid"}, + wantIndex: -1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + index, hasValue := serviceFlagPosition(tc.entries, "--intercept-mode") + if index != tc.wantIndex || hasValue != tc.wantHasValue { + t.Fatalf("serviceFlagPosition() = (%d, %v), want (%d, %v)", index, hasValue, tc.wantIndex, tc.wantHasValue) + } + }) + } +} diff --git a/cmd/cli/service_args_others.go b/cmd/cli/service_args_others.go index 07edda2..b6eb688 100644 --- a/cmd/cli/service_args_others.go +++ b/cmd/cli/service_args_others.go @@ -3,10 +3,14 @@ package cli import ( - "fmt" + "errors" "os" ) +// errServiceFlagsUnsupported is returned by the service-argument helpers on +// platforms that do not store service arguments in a file ctrld can rewrite. +var errServiceFlagsUnsupported = errors.New("modifying service flags is not supported on this platform; use intercept_mode in config instead") + // serviceConfigFileExists checks common service config file locations on Linux. func serviceConfigFileExists() bool { // systemd unit file @@ -24,7 +28,7 @@ func serviceConfigFileExists() bool { // Linux services (systemd) store args in unit files; intercept mode // should be set via the config file (intercept_mode) on these platforms. func appendServiceFlag(flag string) error { - return fmt.Errorf("appending service flags is not supported on this platform; use intercept_mode in config instead") + return errServiceFlagsUnsupported } // verifyServiceRegistration is a no-op on this platform. @@ -34,5 +38,5 @@ func verifyServiceRegistration() error { // removeServiceFlag is not yet implemented on this platform. func removeServiceFlag(flag string) error { - return fmt.Errorf("removing service flags is not supported on this platform; use intercept_mode in config instead") + return errServiceFlagsUnsupported } diff --git a/cmd/cli/service_args_windows.go b/cmd/cli/service_args_windows.go index 246a009..1eed7da 100644 --- a/cmd/cli/service_args_windows.go +++ b/cmd/cli/service_args_windows.go @@ -47,8 +47,9 @@ func appendServiceFlag(flag string) error { return fmt.Errorf("failed to read service config: %w", err) } - // Check if flag already present (idempotent). - if strings.Contains(config.BinaryPathName, flag) { + // Check exact arguments so a short mode such as "off" is not confused with + // an unrelated path or value. + if binaryPathArgumentPresent(config.BinaryPathName, flag) { mainLog.Load().Debug().Msgf("Service flag %q already present in BinPath, skipping", flag) return nil } @@ -103,9 +104,8 @@ func verifyServiceRegistration() error { return nil } -// removeServiceFlag removes a CLI flag (and its value, if present) from the installed -// Windows service's BinPath. For example, removing "--intercept-mode" also removes -// the following "dns" or "hard" value. The function is idempotent. +// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the +// installed Windows service's BinPath. The function is idempotent. func removeServiceFlag(flag string) error { m, err := mgr.Connect() if err != nil { @@ -124,25 +124,12 @@ func removeServiceFlag(flag string) error { return fmt.Errorf("failed to read service config: %w", err) } - if !strings.Contains(config.BinaryPathName, flag) { + updatedPath, removed := removeBinaryPathFlag(config.BinaryPathName, flag) + if !removed { mainLog.Load().Debug().Msgf("Service flag %q not present in BinPath, skipping removal", flag) return nil } - - // Split BinPath into parts, find and remove the flag + its value (if any). - parts := strings.Fields(config.BinaryPathName) - var newParts []string - for i := 0; i < len(parts); i++ { - if parts[i] == flag { - // Skip the flag. Also skip the next part if it's a value (not a flag). - if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") { - i++ // skip value too - } - continue - } - newParts = append(newParts, parts[i]) - } - config.BinaryPathName = strings.Join(newParts, " ") + config.BinaryPathName = updatedPath if err := s.UpdateConfig(config); err != nil { return fmt.Errorf("failed to update service config: %w", err) @@ -151,3 +138,32 @@ func removeServiceFlag(flag string) error { mainLog.Load().Info().Msgf("Removed %q from service BinPath", flag) return nil } + +func binaryPathArgumentPresent(binaryPath, argument string) bool { + for _, part := range strings.Fields(binaryPath) { + if part == argument { + return true + } + } + return false +} + +func removeBinaryPathFlag(binaryPath, flag string) (string, bool) { + parts := strings.Fields(binaryPath) + newParts := make([]string, 0, len(parts)) + removed := false + for i := 0; i < len(parts); i++ { + switch { + case parts[i] == flag: + removed = true + if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") { + i++ + } + case strings.HasPrefix(parts[i], flag+"="): + removed = true + default: + newParts = append(newParts, parts[i]) + } + } + return strings.Join(newParts, " "), removed +} diff --git a/cmd/cli/service_args_windows_test.go b/cmd/cli/service_args_windows_test.go new file mode 100644 index 0000000..ed8e915 --- /dev/null +++ b/cmd/cli/service_args_windows_test.go @@ -0,0 +1,54 @@ +//go:build windows + +package cli + +import "testing" + +func TestBinaryPathArgumentPresent(t *testing.T) { + path := `C:\ControlD\ctrld.exe run --config=C:\Users\officer\ctrld.toml --intercept-mode=dns` + if !binaryPathArgumentPresent(path, "--intercept-mode=dns") { + t.Fatal("exact inline argument was not found") + } + if binaryPathArgumentPresent(path, "--intercept-mode") { + t.Fatal("inline flag was mistaken for a separate flag argument") + } + if binaryPathArgumentPresent(path, "off") { + t.Fatal("substring in an unrelated path was mistaken for the off argument") + } +} + +func TestRemoveBinaryPathFlag(t *testing.T) { + tests := []struct { + name string + binaryPath string + wantPath string + wantRemoved bool + }{ + { + name: "split form", + binaryPath: `ctrld.exe run --cd=uid --intercept-mode dns --config=ctrld.toml`, + wantPath: `ctrld.exe run --cd=uid --config=ctrld.toml`, + wantRemoved: true, + }, + { + name: "inline form", + binaryPath: `ctrld.exe run --cd=uid --intercept-mode=dns --config=ctrld.toml`, + wantPath: `ctrld.exe run --cd=uid --config=ctrld.toml`, + wantRemoved: true, + }, + { + name: "absent", + binaryPath: `ctrld.exe run --cd=uid`, + wantPath: `ctrld.exe run --cd=uid`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path, removed := removeBinaryPathFlag(tc.binaryPath, "--intercept-mode") + if path != tc.wantPath || removed != tc.wantRemoved { + t.Fatalf("removeBinaryPathFlag() = (%q, %v), want (%q, %v)", path, removed, tc.wantPath, tc.wantRemoved) + } + }) + } +} diff --git a/test-scripts/darwin/test-pkg-intercept-mode.sh b/test-scripts/darwin/test-pkg-intercept-mode.sh new file mode 100755 index 0000000..eb06e45 --- /dev/null +++ b/test-scripts/darwin/test-pkg-intercept-mode.sh @@ -0,0 +1,123 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +postinstall="$repo_root/scripts/pkg/postinstall" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/ctrld-pkg-intercept.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +bin="$fixture/bin" +mkdir -p "$bin" + +cat >"$bin/defaults" <<'EOF' +#!/bin/sh +key=${3:-} +case "$key" in + ProvisionToken) + [ "${FAKE_TOKEN_PRESENT:-0}" = "1" ] || exit 1 + printf '%s\n' "${FAKE_TOKEN:-test-token}" + ;; + InterceptMode) + [ "${FAKE_MODE_PRESENT:-0}" = "1" ] || exit 1 + printf '%s\n' "${FAKE_MODE:-}" + ;; + CustomHostname|UseDevEnvironment) + exit 1 + ;; + *) + exit 1 + ;; +esac +EOF + +cat >"$bin/launchctl" <<'EOF' +#!/bin/sh +printf 'launchctl %s\n' "$*" >>"$CALLS" +exit 0 +EOF + +cat >"$bin/ctrld" <<'EOF' +#!/bin/sh +printf 'ctrld %s\n' "$*" >>"$CALLS" +case " $* " in + *" --cd-org="*) : >"$CTRLD_POSTINSTALL_PLIST" ;; +esac +exit 0 +EOF + +chmod +x "$bin/defaults" "$bin/launchctl" "$bin/ctrld" + +assert_contains() { + expected=$1 + file=$2 + if ! grep -Fq -- "$expected" "$file"; then + printf 'FAIL: expected %s in %s\n' "$expected" "$file" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi +} + +assert_not_contains() { + unexpected=$1 + file=$2 + if grep -Fq -- "$unexpected" "$file"; then + printf 'FAIL: did not expect %s in %s\n' "$unexpected" "$file" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi +} + +run_case() { + name=$1 + existing=$2 + mode_present=$3 + mode=$4 + case_dir="$fixture/$name" + mkdir -p "$case_dir" + plist="$case_dir/ctrld.plist" + prefs="$case_dir/preferences" + calls="$case_dir/calls" + output="$case_dir/output" + : >"$calls" + if [ "$existing" = "1" ]; then + : >"$plist" + fi + + PATH="$bin:$PATH" \ + CALLS="$calls" \ + FAKE_TOKEN_PRESENT=1 \ + FAKE_TOKEN=test-token \ + FAKE_MODE_PRESENT="$mode_present" \ + FAKE_MODE="$mode" \ + CTRLD_POSTINSTALL_PLIST="$plist" \ + CTRLD_POSTINSTALL_CTRLD="$bin/ctrld" \ + CTRLD_POSTINSTALL_PREFS="$prefs" \ + "$postinstall" >"$output" 2>&1 + + printf '%s\n' "$case_dir" +} + +case_dir=$(run_case fresh-legacy 0 0 '') +assert_contains 'ctrld start --cd-org=test-token' "$case_dir/calls" +assert_not_contains '--intercept-mode' "$case_dir/calls" + +case_dir=$(run_case fresh-standard 0 1 standard) +assert_contains 'ctrld start --cd-org=test-token' "$case_dir/calls" +assert_not_contains '--intercept-mode' "$case_dir/calls" + +case_dir=$(run_case fresh-intercept 0 1 intercept-dns) +assert_contains 'ctrld start --cd-org=test-token --intercept-mode dns' "$case_dir/calls" + +case_dir=$(run_case upgrade-legacy 1 0 '') +assert_contains 'launchctl load' "$case_dir/calls" +assert_not_contains 'ctrld start' "$case_dir/calls" + +case_dir=$(run_case upgrade-standard 1 1 standard) +assert_contains 'ctrld start --intercept-mode off' "$case_dir/calls" +assert_not_contains 'launchctl load' "$case_dir/calls" + +case_dir=$(run_case upgrade-intercept 1 1 intercept-dns) +assert_contains 'ctrld start --intercept-mode dns' "$case_dir/calls" +assert_not_contains 'launchctl load' "$case_dir/calls" + +printf 'PASS: pkg postinstall preserves legacy mode and applies standard/intercept-dns policy\n'