package apt_test import ( "context" "errors" "os" "path/filepath" "strings" "testing" "proxyctl/internal/apt" "proxyctl/internal/profile" "proxyctl/internal/target" "proxyctl/internal/target/targettest" ) func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *testing.T) { for _, tc := range []struct { name string marker, tool, dir, dumpWorks bool want bool }{ {"detected", true, true, true, true, true}, {"not Debian", false, true, true, true, false}, {"apt-config absent", true, false, true, true, false}, {"configuration absent", true, true, false, true, false}, {"apt-config inspection fails", true, true, true, false, false}, } { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() configDir := filepath.Join(root, "etc", "apt", "apt.conf.d") if tc.dir { mustMkdir(t, configDir) } if tc.marker { mustMkdir(t, filepath.Dir(filepath.Join(root, "etc", "debian_version"))) mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n") } runner := &fakeRunner{available: tc.tool, outputError: !tc.dumpWorks} a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, TrustedRoot: root}, nil, runner) got := a.Availability(context.Background()) if got.Available != tc.want || got.Evidence == "" { t.Fatalf("availability = %#v", got) } }) } } func TestDetectClassifiesInspectionFailureAndUsesOneEffectiveConfigurationProbe(t *testing.T) { a, runner := newAdapter(t, nil) runner.outputError = true detection := a.Detect(context.Background(), compatibleProfile()) if detection.Err == nil || detection.Inspection.State != target.Disabled { t.Fatalf("detection = %#v, want failed Disabled inspection", detection) } if runner.outputs != 1 { t.Fatalf("Detect issued %d apt-config dumps, want one", runner.outputs) } } func TestDiagnosticChecksCoverCommandSchemaAndAuthorityWithoutMutation(t *testing.T) { a, _ := newAdapter(t, nil) checks := a.DiagnosticChecks(context.Background(), compatibleProfile()) assertDiagnosticNames(t, checks, "required command", "configuration schema", "write authority") if _, err := os.Stat(a.OverlayPath()); !errors.Is(err, os.ErrNotExist) { t.Fatalf("diagnostics created APT overlay: %v", err) } } func assertDiagnosticNames(t *testing.T, checks []target.Check, names ...string) { t.Helper() for _, name := range names { found := false for _, check := range checks { if check.Name == name { found = true } } if !found { t.Errorf("diagnostics missing %q: %#v", name, checks) } } } func TestDescriptionExplainsEnvironmentPersistenceAndPrivilegeBoundary(t *testing.T) { a, _ := newAdapter(t, nil) got := a.Description() combined := got.Summary + " " + got.Details for _, phrase := range []string{"environment variables", "persistent", "privilege"} { if !strings.Contains(strings.ToLower(combined), strings.ToLower(phrase)) { t.Errorf("description does not mention %q: %#v", phrase, got) } } } func TestAPTAdapterRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { a, runner := newAdapter(t, nil) return targettest.Fixture{ Target: a, Active: compatibleProfile(), Secret: "secret", MakeExternal: func(t *testing.T) { runner.external = `Acquire::http::Proxy "http://prior.example:3128";` }, MakeDrifted: func(t *testing.T) { contents := readFile(t, a.OverlayPath()) mustWrite(t, a.OverlayPath(), strings.Replace(contents, "http://alice:secret@proxy.example:8080", "http://changed.example:8080", 1)) }, PermissionFailure: func(t *testing.T) target.OperationResult { failed, _ := newFailingAdapter(t, &failingFS{permission: true}) return failed.SetDesired(context.Background(), true, compatibleProfile()) }, PartialFailure: func(t *testing.T) target.OperationResult { failed, failedRunner := newAdapter(t, nil) failedRunner.failDumpAfterWrite = failed.OverlayPath() return failed.SetDesired(context.Background(), true, compatibleProfile()) }, RedactionFailure: func(t *testing.T) target.OperationResult { failed, _ := newFailingAdapter(t, &failingFS{writeError: errors.New("write http://alice:secret@proxy.example:8080 failed")}) return failed.SetDesired(context.Background(), true, compatibleProfile()) }, RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) { files := &failingFS{failOnce: true} failed, runner := newFailingAdapter(t, files) runner.external = `Acquire::http::Proxy "http://prior.example:3128";` first := failed.SetDesired(context.Background(), true, compatibleProfile()) retry := failed.SetDesired(context.Background(), true, compatibleProfile()) disabled := failed.SetDesired(context.Background(), false, nil) return first, retry, disabled }, } }) } func TestAPTConfigurationKeysAndHostOverridesAreCaseInsensitive(t *testing.T) { a, runner := newAdapter(t, nil) runner.external = "aCqUiRe::HtTp::pRoXy \"http://prior.example:3128\";\n" + "ACQUIRE::HTTP::PROXY::SPECIAL.EXAMPLE \"http://special.example:3128\";" if got := a.Inspect(context.Background(), compatibleProfile()); got.State != target.External { t.Fatalf("mixed-case external inspection = %#v", got) } if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } managed := readFile(t, a.OverlayPath()) if !strings.Contains(managed, `Acquire::http::Proxy::special.example "http://alice:secret@proxy.example:8080";`) { t.Fatalf("mixed-case host override was not masked case-insensitively:\n%s", managed) } mustWrite(t, a.OverlayPath(), strings.ReplaceAll(managed, "Acquire::http::Proxy", "ACQUIRE::HTTP::PROXY")) if got := a.Inspect(context.Background(), compatibleProfile()); got.State != target.Enabled { t.Fatalf("mixed-case managed inspection = %#v", got) } } func TestMissingProtocolWritesDIRECTAndDisableRestoresUnderlyingConfiguration(t *testing.T) { for _, tc := range []struct { name, configuredKey, configuredURL, missingKey, priorURL string }{ {"missing HTTPS", "Acquire::http::Proxy", "http://proxy.example:8080", "Acquire::https::Proxy", "https://prior.example:8443"}, {"missing HTTP", "Acquire::https::Proxy", "https://proxy.example:8443", "Acquire::http::Proxy", "http://prior.example:8080"}, } { t.Run(tc.name, func(t *testing.T) { a, runner := newAdapter(t, nil) runner.external = tc.missingKey + ` "` + tc.priorURL + `";` p := &profile.Profile{Name: "partial", Mode: profile.AdvancedMode} if strings.Contains(tc.configuredKey, "http::") { p.HTTPURL = tc.configuredURL } else { p.HTTPSURL = tc.configuredURL } if got := a.SetDesired(context.Background(), true, p); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } managed := readFile(t, a.OverlayPath()) if !strings.Contains(managed, tc.configuredKey+` "`+tc.configuredURL+`";`) || !strings.Contains(managed, tc.missingKey+` "DIRECT";`) { t.Fatalf("overlay does not explicitly mask missing protocol:\n%s", managed) } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("disable = %#v", got) } if !strings.Contains(runner.external, tc.priorURL) { t.Fatalf("underlying configuration changed: %q", runner.external) } }) } } func TestOwnedOverlayWinsAndDisableRevealsPriorEffectiveConfiguration(t *testing.T) { a, runner := newAdapter(t, nil) runner.external = "Acquire::http::Proxy \"http://prior.example:3128\";\n" + "Acquire::http::Proxy::special.example \"http://special.example:3128\";" if got := a.Inspect(context.Background(), compatibleProfile()); got.State != target.External || !strings.Contains(got.Evidence, "Acquire::http::Proxy") { t.Fatalf("external inspection = %#v", got) } if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } managed := readFile(t, a.OverlayPath()) for _, expected := range []string{ `Acquire::http::Proxy "http://alice:secret@proxy.example:8080";`, `Acquire::https::Proxy "https://proxy.example:8443";`, `Acquire::http::Proxy::localhost "DIRECT";`, `Acquire::https::Proxy::10.0.0.1 "DIRECT";`, `Acquire::http::Proxy::special.example "http://alice:secret@proxy.example:8080";`, } { if !strings.Contains(managed, expected) { t.Errorf("overlay missing %q:\n%s", expected, managed) } } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("disable = %#v", got) } if _, err := os.Stat(a.OverlayPath()); !errors.Is(err, os.ErrNotExist) { t.Fatalf("managed overlay remains: %v", err) } if !strings.Contains(runner.external, `Acquire::http::Proxy "http://prior.example:3128";`) || !strings.Contains(runner.external, `Acquire::http::Proxy::special.example "http://special.example:3128";`) { t.Fatal("unrelated APT configuration changed") } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("idempotent disable = %#v", got) } } func TestOverlayNameCollisionNeverRewritesOrRemovesUnownedConfiguration(t *testing.T) { a, _ := newAdapter(t, nil) original := `Acquire::http::Proxy "http://user-owned.example:3128";` + "\n" mustWrite(t, a.OverlayPath(), original) if got := a.Inspect(context.Background(), compatibleProfile()); got.State != target.External { t.Fatalf("unowned collision inspection = %#v", got) } got := a.SetDesired(context.Background(), true, compatibleProfile()) if got.Err == nil || !strings.Contains(got.Err.Error(), "not owned") || readFile(t, a.OverlayPath()) != original { t.Fatalf("enable over collision = %#v, file %q", got, readFile(t, a.OverlayPath())) } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External || readFile(t, a.OverlayPath()) != original { t.Fatalf("disable over collision = %#v", got) } } func TestDefaultOverlayNameSortsAfterCommonAPTFragments(t *testing.T) { name := filepath.Base(apt.New(apt.Configuration{}, nil, nil).OverlayPath()) if filepath.Ext(name) != ".conf" { t.Fatalf("default overlay name %q has no .conf extension; stricter APT versions ignore extensionless fragments", name) } for _, fragment := range []string{"proxy.conf", "99update-notifier", "01-vendor-ubuntu"} { if strings.Compare(name, fragment) <= 0 { t.Fatalf("default overlay name %q does not sort after common fragment %q; a later fragment shadows the overlay and enable can never become effective", name, fragment) } } } func TestOwnedLegacyOverlayIsRemovedOnEnableAndDisable(t *testing.T) { legacyContents := "// Generated by proxyctl. Do not edit.\nAcquire::http::Proxy \"http://stale.example:3128\";\n" t.Run("enable", func(t *testing.T) { a, _ := newAdapter(t, nil) legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl") mustWrite(t, legacy, legacyContents) if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) { t.Fatalf("owned legacy overlay remains after enable: %v", err) } }) t.Run("disable", func(t *testing.T) { a, _ := newAdapter(t, nil) legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl") mustWrite(t, legacy, legacyContents) if got := a.SetDesired(context.Background(), false, nil); got.Err != nil { t.Fatalf("disable = %#v", got) } if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) { t.Fatalf("owned legacy overlay remains after disable: %v", err) } }) t.Run("unowned legacy is untouched", func(t *testing.T) { a, _ := newAdapter(t, nil) legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl") original := "Acquire::http::Proxy \"http://user-owned.example:3128\";\n" mustWrite(t, legacy, original) if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } if got := readFile(t, legacy); got != original { t.Fatalf("unowned legacy overlay changed: %q", got) } }) } func TestDisablePermissionFailureReportsActualManagedState(t *testing.T) { files := &failingFS{} a, _ := newFailingAdapter(t, files) if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil { t.Fatalf("enable = %#v", got) } files.removePermission = true got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Drifted { t.Fatalf("permission-denied disable = %#v", got) } if _, err := os.Stat(a.OverlayPath()); err != nil { t.Fatalf("managed overlay should remain after denied removal: %v", err) } } func TestEnableReturnsFailureWhenManagedValuesDoNotBecomeEffective(t *testing.T) { a, runner := newAdapter(t, nil) runner.override = `Acquire::http::Proxy "http://later.example:8080";` got := a.SetDesired(context.Background(), true, compatibleProfile()) if got.Err == nil || got.Inspection.State != target.Drifted { t.Fatalf("ineffective enable = %#v", got) } } func TestInspectionUsesEffectiveAPTConfiguration(t *testing.T) { a, runner := newAdapter(t, nil) if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil { t.Fatal(got.Err) } runner.override = `Acquire::http::Proxy "http://later.example:8080";` if got := a.Inspect(context.Background(), compatibleProfile()); got.State != target.Drifted || !strings.Contains(got.Evidence, "effective") { t.Fatalf("higher-precedence effective config = %#v", got) } } func TestIncompatibleProtocolOrBypassPreventsMutationAndNamesValue(t *testing.T) { cases := []struct { name string mutate func(*profile.Profile) value string }{ {"socks5 local DNS", func(p *profile.Profile) { p.HTTPURL = "socks5://unsupported.example:1080" }, "socks5://unsupported.example:1080"}, {"incompatible ALL fallback", func(p *profile.Profile) { p.HTTPURL = ""; p.AllURL = "socks5://all.example:1080" }, "socks5://all.example:1080"}, {"domain suffix", func(p *profile.Profile) { p.Bypass = []string{".example.com"} }, ".example.com"}, {"CIDR", func(p *profile.Profile) { p.Bypass = []string{"10.0.0.0/8"} }, "10.0.0.0/8"}, {"IPv6", func(p *profile.Profile) { p.Bypass = []string{"2001:db8::1"} }, "2001:db8::1"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { a, _ := newAdapter(t, nil) p := compatibleProfile() tc.mutate(p) got := a.SetDesired(context.Background(), true, p) if got.Err == nil || !strings.Contains(got.Err.Error(), tc.value) || got.Inspection.State != target.Disabled { t.Fatalf("result = %#v", got) } if _, err := os.Stat(a.OverlayPath()); !errors.Is(err, os.ErrNotExist) { t.Fatalf("incompatible Profile mutated overlay: %v", err) } }) } } func TestManagedOverlayIsAtomicOwnerOnlyAndRejectsSymlinkVictims(t *testing.T) { t.Run("owner-only atomic file", func(t *testing.T) { a, _ := newAdapter(t, nil) if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil { t.Fatal(got.Err) } info, err := os.Stat(a.OverlayPath()) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("overlay mode = %o", info.Mode().Perm()) } matches, _ := filepath.Glob(filepath.Join(filepath.Dir(a.OverlayPath()), ".proxyctl-*")) if len(matches) != 0 { t.Fatalf("atomic temporary files remain: %v", matches) } }) t.Run("final symlink", func(t *testing.T) { a, _ := newAdapter(t, nil) victim := filepath.Join(t.TempDir(), "victim") mustWrite(t, victim, "unchanged\n") if err := os.Symlink(victim, a.OverlayPath()); err != nil { t.Fatal(err) } if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err == nil { t.Fatal("enable followed a symlinked overlay") } if got := readFile(t, victim); got != "unchanged\n" { t.Fatalf("symlink victim changed: %q", got) } }) } func TestCompleteHTTPProfileIgnoresAdditionalSOCKSProxy(t *testing.T) { a, _ := newAdapter(t, nil) p := &profile.Profile{ Name: "dual", Mode: profile.AdvancedMode, HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890", } got := a.SetDesired(context.Background(), true, p) if got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } contents := readFile(t, a.OverlayPath()) if strings.Count(contents, `"http://localhost:7890"`) != 2 || strings.Contains(contents, "socks5") { t.Fatalf("dual-protocol translation:\n%s", contents) } } func TestAdvancedALLValueSuppliesMissingAPTProtocols(t *testing.T) { a, _ := newAdapter(t, nil) p := &profile.Profile{Name: "all", Mode: profile.AdvancedMode, AllURL: "socks5h://proxy.example:1080"} if got := a.SetDesired(context.Background(), true, p); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } contents := readFile(t, a.OverlayPath()) if strings.Count(contents, `"socks5h://proxy.example:1080"`) != 2 { t.Fatalf("ALL translation did not supply both APT protocols:\n%s", contents) } } func TestSingleModeUsesOneSupportedValueWithoutTreatingExpansionAsALL(t *testing.T) { a, _ := newAdapter(t, nil) p := &profile.Profile{Name: "single", Mode: profile.SingleMode, SingleURL: "socks5h://proxy.example:1080", Bypass: []string{"localhost"}} if got := a.SetDesired(context.Background(), true, p); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } contents := readFile(t, a.OverlayPath()) if !strings.Contains(contents, `Acquire::http::Proxy "socks5h://proxy.example:1080";`) || !strings.Contains(contents, `Acquire::https::Proxy "socks5h://proxy.example:1080";`) { t.Fatalf("single translation:\n%s", contents) } } func compatibleProfile() *profile.Profile { return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", Bypass: []string{"localhost", "10.0.0.1"}} } func newAdapter(t *testing.T, files apt.FileSystem) (*apt.Adapter, *fakeRunner) { t.Helper() root := t.TempDir() dir := filepath.Join(root, "etc", "apt", "apt.conf.d") mustMkdir(t, dir) mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n") runner := &fakeRunner{available: true} if failure, ok := files.(*failingFS); ok { failure.delegate = apt.NewOSFileSystem(root) } a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: dir, TrustedRoot: root}, files, runner) runner.overlay = a.OverlayPath() return a, runner } func newFailingAdapter(t *testing.T, files *failingFS) (*apt.Adapter, *fakeRunner) { return newAdapter(t, files) } type fakeRunner struct { available, outputError bool overlay, external, override, failDumpAfterWrite string outputs int } func (r *fakeRunner) LookPath(string) (string, error) { if !r.available { return "", os.ErrNotExist } return "/usr/bin/apt-config", nil } func (r *fakeRunner) Output(context.Context, string, ...string) ([]byte, error) { r.outputs++ if r.outputError { return nil, errors.New("apt-config dump failed") } if r.failDumpAfterWrite != "" { if _, err := os.Stat(r.failDumpAfterWrite); err == nil { return nil, errors.New("apt-config dump failed") } } if r.override != "" { return []byte(r.override + "\n"), nil } if r.overlay != "" { if data, err := os.ReadFile(r.overlay); err == nil { return []byte(r.external + "\n" + string(data)), nil } } return []byte(r.external + "\n"), nil } type failingFS struct { delegate apt.FileSystem permission, failOnce, removePermission bool writeError error } func (f *failingFS) ReadFile(path string) ([]byte, error) { return f.delegate.ReadFile(path) } func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode) error { if f.permission { return os.ErrPermission } if f.writeError != nil { return f.writeError } if f.failOnce { f.failOnce = false return os.ErrPermission } return f.delegate.AtomicWrite(path, data, mode) } func (f *failingFS) Remove(path string) error { if f.removePermission { return os.ErrPermission } return f.delegate.Remove(path) } func mustMkdir(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } } func mustWrite(t *testing.T, path, value string) { t.Helper() if err := os.WriteFile(path, []byte(value), 0o644); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) }