Files
proxy/internal/apt/apt_test.go
T

534 lines
21 KiB
Go

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)
}