From 43b75379555ce06620593f336b3da53d2e99097b Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Fri, 17 Jul 2026 03:26:25 +0800 Subject: [PATCH] feat: add persistent APT Target adapter --- internal/apt/apt.go | 459 +++++++++++++++++++++++++++++++++++++++ internal/apt/apt_test.go | 344 +++++++++++++++++++++++++++++ 2 files changed, 803 insertions(+) create mode 100644 internal/apt/apt.go create mode 100644 internal/apt/apt_test.go diff --git a/internal/apt/apt.go b/internal/apt/apt.go new file mode 100644 index 0000000..9655fef --- /dev/null +++ b/internal/apt/apt.go @@ -0,0 +1,459 @@ +// Package apt implements the persistent Debian-family APT Target. +package apt + +import ( + "bufio" + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "proxyctl/internal/profile" + "proxyctl/internal/securefs" + "proxyctl/internal/target" +) + +const ( + overlayName = "99proxyctl" + overlayHeader = "// Generated by proxyctl. Do not edit.\n" +) + +// Configuration names the Debian marker and the isolated APT configuration +// fragment. TrustedRoot anchors all mutations and is injectable for tests. +type Configuration struct { + DebianVersionFile string + ConfigDirectory string + OverlayFile string + TrustedRoot string +} + +func DefaultConfiguration() Configuration { + return Configuration{ + DebianVersionFile: "/etc/debian_version", + ConfigDirectory: "/etc/apt/apt.conf.d", + OverlayFile: "/etc/apt/apt.conf.d/" + overlayName, + TrustedRoot: "/", + } +} + +// FileSystem is the mutation seam used by the Adapter. Availability probes are +// read-only os.Stat calls, while writes remain descriptor-anchored in securefs. +type FileSystem interface { + ReadFile(string) ([]byte, error) + AtomicWrite(string, []byte, os.FileMode) error + Remove(string) error +} + +type OSFileSystem struct{ TrustedRoot string } + +func NewOSFileSystem(trustedRoot string) OSFileSystem { return OSFileSystem{TrustedRoot: trustedRoot} } +func (f OSFileSystem) store() securefs.Store { return securefs.Store{TrustedRoot: f.TrustedRoot} } +func (f OSFileSystem) ReadFile(path string) ([]byte, error) { + return f.store().ReadFile(path) +} +func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode) error { + return f.store().AtomicWrite(path, data, mode) +} +func (f OSFileSystem) Remove(path string) error { return f.store().Remove(path) } + +// Runner isolates apt-config discovery and execution from adapter tests. +type Runner interface { + LookPath(string) (string, error) + Output(context.Context, string, ...string) ([]byte, error) +} + +type systemRunner struct{} + +func (systemRunner) LookPath(name string) (string, error) { return exec.LookPath(name) } +func (systemRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +type Adapter struct { + configuration Configuration + files FileSystem + runner Runner +} + +func New(configuration Configuration, files FileSystem, runner Runner) *Adapter { + defaults := DefaultConfiguration() + if configuration.DebianVersionFile == "" { + configuration.DebianVersionFile = defaults.DebianVersionFile + } + if configuration.ConfigDirectory == "" { + configuration.ConfigDirectory = defaults.ConfigDirectory + } + if configuration.OverlayFile == "" { + configuration.OverlayFile = filepath.Join(configuration.ConfigDirectory, overlayName) + } + if configuration.TrustedRoot == "" { + configuration.TrustedRoot = defaults.TrustedRoot + } + if files == nil { + files = NewOSFileSystem(configuration.TrustedRoot) + } + if runner == nil { + runner = systemRunner{} + } + return &Adapter{configuration: configuration, files: files, runner: runner} +} + +func (a *Adapter) ID() string { return "apt" } +func (a *Adapter) Description() target.Description { + return target.Description{ + Name: "APT", + Summary: "Persistently configures APT proxying across Shell and privilege boundaries.", + Details: "APT supports proxy environment variables; this integration uses an isolated persistent configuration overlay so package operations remain reliable across privilege boundaries. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges.", + } +} + +func (a *Adapter) Availability(ctx context.Context) target.Availability { + marker, err := os.Stat(a.configuration.DebianVersionFile) + if err != nil || !marker.Mode().IsRegular() { + return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"} + } + directory, err := os.Stat(a.configuration.ConfigDirectory) + if err != nil || !directory.IsDir() { + return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"} + } + command, err := a.runner.LookPath("apt-config") + if err != nil { + return target.Availability{Evidence: "Debian APT tool apt-config was not detected"} + } + if _, err := a.runner.Output(ctx, command, "dump"); err != nil { + return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"} + } + return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"} +} + +func (a *Adapter) OverlayPath() string { return a.configuration.OverlayFile } + +func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection { + inspection, _ := a.inspect(ctx, active) + return inspection +} + +func (a *Adapter) inspect(ctx context.Context, active *profile.Profile) (target.Inspection, error) { + overlay, overlayErr := a.files.ReadFile(a.configuration.OverlayFile) + managed := overlayErr == nil && isOwnedOverlay(overlay) + if overlayErr != nil && !errors.Is(overlayErr, fs.ErrNotExist) { + return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay cannot be read"}, fmt.Errorf("read managed APT overlay: %w", overlayErr) + } + effective, dumpErr := a.dump(ctx) + if dumpErr != nil { + if managed { + return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay exists but effective configuration cannot be inspected"}, dumpErr + } + return target.Inspection{State: target.Disabled, Evidence: "effective APT configuration could not be positively inspected"}, dumpErr + } + effectiveValues := parseConfiguration(effective) + if managed { + managedValues := parseConfiguration(overlay) + if len(managedValues) == 0 { + return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay contains no recognized proxy settings"}, nil + } + if active == nil { + return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay exists without an Active Profile"}, nil + } + desired, err := translate(*active) + if err == nil { + coverExistingHostRules(desired.settings, effectiveValues) + } + if err == nil && equalSettings(managedValues, desired.settings) && equalSettings(effectiveValues, desired.settings) { + return target.Inspection{State: target.Enabled, Evidence: "effective managed APT values match the Active Profile"}, nil + } + return target.Inspection{State: target.Drifted, Evidence: "effective managed APT values do not match the Active Profile"}, nil + } + if key, ok := recognizedProxy(effectiveValues); ok { + return target.Inspection{State: target.External, Evidence: "recognized non-managed APT proxy setting " + key + " is effective"}, nil + } + return target.Inspection{State: target.Disabled, Evidence: "no recognized effective APT proxy configuration detected"}, nil +} + +func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { + if !a.Availability(ctx).Available { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("APT Target is unavailable: Debian APT tooling or configuration capability was not detected")} + } + if enabled && active == nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")} + } + var mutationErr error + if enabled { + if existing, err := a.files.ReadFile(a.configuration.OverlayFile); err == nil && !isOwnedOverlay(existing) { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("APT overlay path already contains configuration not owned by proxyctl; no changes made")} + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(fmt.Errorf("inspect managed APT overlay before writing: %w", err), active)} + } + translated, err := translate(*active) + if err != nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(err, active)} + } + effective, err := a.dump(ctx) + if err != nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(fmt.Errorf("inspect effective APT configuration before mutation: %w", err), active)} + } + coverExistingHostRules(translated.settings, parseConfiguration(effective)) + translated.rendered = render(translated.settings) + mutationErr = a.files.AtomicWrite(a.configuration.OverlayFile, translated.rendered, 0o600) + if mutationErr != nil { + mutationErr = fmt.Errorf("write managed APT overlay: %w", mutationErr) + } + } else { + existing, err := a.files.ReadFile(a.configuration.OverlayFile) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + mutationErr = fmt.Errorf("inspect managed APT overlay before removal: %w", err) + case !isOwnedOverlay(existing): + // A path collision is user-owned configuration, not managed state. + default: + if err := a.files.Remove(a.configuration.OverlayFile); err != nil && !errors.Is(err, fs.ErrNotExist) { + mutationErr = fmt.Errorf("remove managed APT overlay: %w", err) + } + } + } + inspection, inspectErr := a.inspect(ctx, active) + if mutationErr == nil && inspectErr != nil { + mutationErr = fmt.Errorf("re-inspect effective APT configuration after mutation: %w", inspectErr) + } + if mutationErr == nil && enabled && inspection.State != target.Enabled { + mutationErr = errors.New("managed APT overlay was written but did not become the effective Active Profile configuration") + } + if mutationErr == nil && !enabled && inspection.State == target.Drifted { + mutationErr = errors.New("managed APT overlay removal did not complete") + } + return target.OperationResult{Inspection: inspection, Err: redactError(mutationErr, active)} +} + +func (a *Adapter) dump(ctx context.Context) ([]byte, error) { + command, err := a.runner.LookPath("apt-config") + if err != nil { + return nil, fmt.Errorf("locate apt-config: %w", err) + } + output, err := a.runner.Output(ctx, command, "dump") + if err != nil { + return nil, fmt.Errorf("run apt-config dump: %w", err) + } + return output, nil +} + +type translation struct { + settings map[string]string + rendered []byte +} + +func translate(input profile.Profile) (translation, error) { + p, err := profile.ValidateAndNormalize(input) + if err != nil { + return translation{}, err + } + httpValue, httpsValue := p.HTTPURL, p.HTTPSURL + if httpValue == "" { + httpValue = p.AllURL + } + if httpsValue == "" { + httpsValue = p.AllURL + } + for _, candidate := range []struct { + field, value string + }{{"HTTP proxy", p.HTTPURL}, {"HTTPS proxy", p.HTTPSURL}, {"ALL/SOCKS proxy", p.AllURL}} { + if candidate.value == "" { + continue + } + parsed, _ := url.Parse(candidate.value) + if parsed.Scheme != "http" && parsed.Scheme != "https" && parsed.Scheme != "socks5h" { + return translation{}, fmt.Errorf("APT cannot translate %s value %q: proxy scheme %q is incompatible", candidate.field, profile.ObscureURL(candidate.value), parsed.Scheme) + } + } + settings := make(map[string]string) + if httpValue != "" { + settings["Acquire::http::Proxy"] = httpValue + } + if httpsValue != "" { + settings["Acquire::https::Proxy"] = httpsValue + } + for _, bypass := range p.Bypass { + if strings.HasPrefix(bypass, ".") || strings.Contains(bypass, "/") || strings.Contains(bypass, ":") { + return translation{}, fmt.Errorf("APT cannot translate Bypass entry %q; only exact hostnames and IPv4 addresses are supported", bypass) + } + if net.ParseIP(bypass) == nil && strings.ContainsAny(bypass, " \t\r\n;\"") { + return translation{}, fmt.Errorf("APT cannot translate Bypass entry %q", bypass) + } + settings["Acquire::http::Proxy::"+bypass] = "DIRECT" + settings["Acquire::https::Proxy::"+bypass] = "DIRECT" + } + return translation{settings: settings, rendered: render(settings)}, nil +} + +var settingOrder = []string{"Acquire::http::Proxy", "Acquire::https::Proxy"} + +func render(settings map[string]string) []byte { + var output strings.Builder + output.WriteString(overlayHeader) + for _, key := range settingOrder { + if value, ok := settings[key]; ok { + fmt.Fprintf(&output, "%s \"%s\";\n", key, aptQuote(value)) + } + } + for _, protocol := range []string{"http", "https"} { + prefix := "Acquire::" + protocol + "::Proxy::" + var keys []string + for key := range settings { + if strings.HasPrefix(key, prefix) { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(&output, "%s \"%s\";\n", key, aptQuote(settings[key])) + } + } + return []byte(output.String()) +} + +func isOwnedOverlay(data []byte) bool { return strings.HasPrefix(string(data), overlayHeader) } + +func aptQuote(value string) string { + return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value) +} + +func parseConfiguration(data []byte) map[string]string { + settings := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + key, rest, ok := strings.Cut(line, " ") + if !ok { + key, rest, ok = strings.Cut(line, "\t") + } + if !ok || !recognizedKey(key) { + continue + } + rest = strings.TrimSpace(rest) + if len(rest) < 3 || rest[0] != '"' || rest[len(rest)-1] != ';' { + continue + } + quoted := strings.TrimSpace(rest[:len(rest)-1]) + if len(quoted) < 2 || quoted[len(quoted)-1] != '"' { + continue + } + settings[key] = aptUnquote(quoted[1 : len(quoted)-1]) + } + return settings +} + +func recognizedKey(key string) bool { + return key == "Acquire::http::Proxy" || key == "Acquire::https::Proxy" || + strings.HasPrefix(key, "Acquire::http::Proxy::") || strings.HasPrefix(key, "Acquire::https::Proxy::") +} + +func recognizedProxy(settings map[string]string) (string, bool) { + keys := make([]string, 0, len(settings)) + for key := range settings { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value := settings[key] + if value != "" && !strings.EqualFold(value, "DIRECT") && !strings.EqualFold(value, "false") { + return key, true + } + } + return "", false +} + +func aptUnquote(value string) string { + var output strings.Builder + escaped := false + for _, r := range value { + if escaped { + output.WriteRune(r) + escaped = false + } else if r == '\\' { + escaped = true + } else { + output.WriteRune(r) + } + } + if escaped { + output.WriteByte('\\') + } + return output.String() +} + +func coverExistingHostRules(desired, effective map[string]string) { + for key := range effective { + var protocol, prefix string + switch { + case strings.HasPrefix(key, "Acquire::http::Proxy::"): + protocol, prefix = "http", "Acquire::http::Proxy::" + case strings.HasPrefix(key, "Acquire::https::Proxy::"): + protocol, prefix = "https", "Acquire::https::Proxy::" + default: + continue + } + if _, alreadyCovered := desired[key]; alreadyCovered { + continue + } + value := desired["Acquire::"+protocol+"::Proxy"] + if value == "" { + value = "DIRECT" + } + // A later fragment can shadow a more-specific earlier host rule only by + // repeating that host key. The earlier file itself remains untouched and + // becomes effective again when the owned overlay is removed. + desired[prefix+strings.TrimPrefix(key, prefix)] = value + } +} + +func equalSettings(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +type redactedError struct { + message string + class error +} + +func (e *redactedError) Error() string { return e.message } +func (e *redactedError) Is(other error) bool { return e.class != nil && errors.Is(e.class, other) } + +func redactError(err error, active *profile.Profile) error { + if err == nil { + return nil + } + message := profile.RedactText(err.Error()) + if active != nil { + for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} { + if raw != "" { + message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw)) + } + } + } + var class error + for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} { + if errors.Is(err, safe) { + class = safe + break + } + } + return &redactedError{message: message, class: class} +} diff --git a/internal/apt/apt_test.go b/internal/apt/apt_test.go new file mode 100644 index 0000000..1ba12d5 --- /dev/null +++ b/internal/apt/apt_test.go @@ -0,0 +1,344 @@ +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 bool + want bool + }{ + {"detected", true, true, true, true}, + {"not Debian", false, true, true, false}, + {"apt-config absent", true, false, true, false}, + {"configuration absent", 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} + a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, OverlayFile: filepath.Join(configDir, "99proxyctl"), TrustedRoot: root}, nil, runner) + got := a.Availability(context.Background()) + if got.Available != tc.want || got.Evidence == "" { + t.Fatalf("availability = %#v", got) + } + }) + } +} + +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 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 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 overridden ALL", func(p *profile.Profile) { 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 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, OverlayFile: filepath.Join(dir, "99proxyctl"), 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 bool + overlay, external, override, failDumpAfterWrite string +} + +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) { + 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 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 { 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) +}