diff --git a/README.md b/README.md index 02d9ff9..4f6555d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ cfg, err := sysproxy.GetConfig() // cfg.HTTPS = "http://proxy.example.com:8080" // cfg.SOCKS = "socks5://proxy.example.com:1080" // cfg.NoProxy = "localhost,10.0.0.0/8" +// cfg.PAC = "" // populated when auto-proxy (PAC) is active instead of a manual proxy ``` `GetConfigContext` is also available. @@ -133,7 +134,7 @@ Note: `SetPAC` switches the OS into auto-proxy (PAC) mode. In that mode, ### Temporary proxy -`WithProxy` sets the proxy for the duration of `fn` and restores the previous state on return — even if `fn` returns an error. +`WithProxy` sets the proxy for the duration of `fn` and restores the previous state on return — even if `fn` returns an error. The snapshot covers the full `ProxyConfig` (HTTP + HTTPS + SOCKS + NoProxy + PAC), not just the HTTP field. ```go ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -146,6 +147,44 @@ err := sysproxy.WithProxy(ctx, "socks5://proxy.example.com:1080", sysproxy.Scope ) ``` +`WithProxyMulti` is the multi-protocol variant: + +```go +err := sysproxy.WithProxyMulti(ctx, sysproxy.ProxyConfig{ + HTTP: "http://http-proxy.example.com:8080", + SOCKS: "socks5://socks-proxy.example.com:1080", +}, sysproxy.ScopeGlobal, func(ctx context.Context) error { + return doWork(ctx) +}) +``` + +### Error classification + +The package exposes sentinel errors so callers can branch precisely with `errors.Is`: + +```go +_, err := sysproxy.GetConfig() +switch { +case errors.Is(err, sysproxy.ErrProxyNotSet): + // no manual proxy configured +case errors.Is(err, sysproxy.ErrProxyNotEnabled): + // proxy entry exists but is disabled (e.g. Windows ProxyEnable=0) +case errors.Is(err, sysproxy.ErrUnsupportedPlatform): + // this GOOS has no sysproxy backend compiled in +} + +// non-critical failures (e.g. /etc/environment without root): +if err := sysproxy.Set(url, sysproxy.ScopeGlobal); err != nil { + if sysproxy.RequiresElevation(err) { + log.Println("sudo required for system-wide effect; per-desktop settings still applied") + } else if sysproxy.IsNonCritical(err) { + log.Println("partial success:", err) + } else { + return err + } +} +``` + ### Health check ```go @@ -243,8 +282,9 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr | Feature | macOS | Linux (GNOME) | Linux (KDE) | Windows | |---|:---:|:---:|:---:|:---:| | Set / Unset | ✓ | ✓ | ✓ | ✓ | -| Get | ✓ | ✓ | — | ✓ | -| GetConfig | ✓ | ✓ | — | ✓ | +| Get | ✓ | ✓ | ✓ | ✓ | +| GetConfig | ✓ | ✓ | ✓ | ✓ | +| GetConfig (PAC field) | ✓ | ✓ | ✓ | ✓ | | SetMulti | ✓ | ✓ | ✓ | ✓ | | SetPAC | ✓ | ✓ | ✓ | ✓ | | ScopeUser (rc files) | ✓ | ✓ | ✓ | ✓ | diff --git a/appconfig.go b/appconfig.go index 8d47618..52d0420 100644 --- a/appconfig.go +++ b/appconfig.go @@ -118,7 +118,7 @@ func runGit(ctx context.Context, args ...string) error { func writeGitProxy(ctx context.Context, proxyURL string) error { if !isAvailable("git") { - return fmt.Errorf("sysproxy: git not found in PATH") + return fmt.Errorf("%w: git", ErrToolMissing) } if err := runGit(ctx, "config", "--global", "http.proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: git config http.proxy: %w", err) @@ -131,7 +131,7 @@ func writeGitProxy(ctx context.Context, proxyURL string) error { func clearGitProxy(ctx context.Context) error { if !isAvailable("git") { - return fmt.Errorf("sysproxy: git not found in PATH") + return fmt.Errorf("%w: git", ErrToolMissing) } _ = runGit(ctx, "config", "--global", "--unset", "http.proxy") _ = runGit(ctx, "config", "--global", "--unset", "https.proxy") @@ -146,7 +146,7 @@ func runNPM(ctx context.Context, args ...string) error { func writeNPMProxy(ctx context.Context, proxyURL string) error { if !isAvailable("npm") { - return fmt.Errorf("sysproxy: npm not found in PATH") + return fmt.Errorf("%w: npm", ErrToolMissing) } if err := runNPM(ctx, "config", "set", "proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: npm config set proxy: %w", err) @@ -159,7 +159,7 @@ func writeNPMProxy(ctx context.Context, proxyURL string) error { func clearNPMProxy(ctx context.Context) error { if !isAvailable("npm") { - return fmt.Errorf("sysproxy: npm not found in PATH") + return fmt.Errorf("%w: npm", ErrToolMissing) } _ = runNPM(ctx, "config", "delete", "proxy") _ = runNPM(ctx, "config", "delete", "https-proxy") diff --git a/appconfig_test.go b/appconfig_test.go index 47c1077..2a116c5 100644 --- a/appconfig_test.go +++ b/appconfig_test.go @@ -2,6 +2,7 @@ package sysproxy import ( "context" + "errors" "os" "path/filepath" "runtime" @@ -106,8 +107,8 @@ func TestWriteGitProxyNotFound(t *testing.T) { t.Setenv("PATH", t.TempDir()) err := writeGitProxy(context.Background(), "http://proxy.example.com:8080") - if err == nil || !strings.Contains(err.Error(), "git not found") { - t.Fatalf("expected git not found error, got %v", err) + if err == nil || !errors.Is(err, ErrToolMissing) { + t.Fatalf("expected ErrToolMissing, got %v", err) } } @@ -535,8 +536,8 @@ func TestClearGitProxyNotFound(t *testing.T) { t.Setenv("PATH", t.TempDir()) err := clearGitProxy(context.Background()) - if err == nil || !strings.Contains(err.Error(), "git not found") { - t.Fatalf("expected git not found error, got %v", err) + if err == nil || !errors.Is(err, ErrToolMissing) { + t.Fatalf("expected ErrToolMissing, got %v", err) } } @@ -544,8 +545,8 @@ func TestWriteNPMProxyNotFound(t *testing.T) { t.Setenv("PATH", t.TempDir()) err := writeNPMProxy(context.Background(), "http://proxy.example.com:8080") - if err == nil || !strings.Contains(err.Error(), "npm not found") { - t.Fatalf("expected npm not found error, got %v", err) + if err == nil || !errors.Is(err, ErrToolMissing) { + t.Fatalf("expected ErrToolMissing, got %v", err) } } @@ -553,8 +554,8 @@ func TestClearNPMProxyNotFound(t *testing.T) { t.Setenv("PATH", t.TempDir()) err := clearNPMProxy(context.Background()) - if err == nil || !strings.Contains(err.Error(), "npm not found") { - t.Fatalf("expected npm not found error, got %v", err) + if err == nil || !errors.Is(err, ErrToolMissing) { + t.Fatalf("expected ErrToolMissing, got %v", err) } } diff --git a/errors.go b/errors.go index 788da5c..103cfee 100644 --- a/errors.go +++ b/errors.go @@ -2,6 +2,28 @@ package sysproxy import "errors" +// Sentinel errors. Callers can match with errors.Is: +// +// if errors.Is(err, sysproxy.ErrProxyNotSet) { ... } +var ( + // ErrProxyNotSet is returned by Get / GetConfig when no manual proxy is + // configured on the current system. + ErrProxyNotSet = errors.New("sysproxy: proxy not set") + + // ErrProxyNotEnabled is returned by Get / GetConfig when the proxy entry + // exists in the OS store but is currently disabled (e.g. Windows registry + // ProxyEnable=0 without an AutoConfigURL). + ErrProxyNotEnabled = errors.New("sysproxy: proxy not enabled") + + // ErrUnsupportedPlatform is returned by backends on operating systems that + // have no sysproxy implementation compiled in. + ErrUnsupportedPlatform = errors.New("sysproxy: unsupported platform") + + // ErrToolMissing is returned when a required external tool + // (gsettings, networksetup, reg, git, npm, ...) is not available in PATH. + ErrToolMissing = errors.New("sysproxy: required tool not found in PATH") +) + // nonCriticalError wraps errors from operations that do not invalidate the // overall request. For example, /etc/environment on Linux requires root; if // the rest of the configuration (GNOME/KDE/process env) succeeded, the caller @@ -34,3 +56,45 @@ func IsNonCritical(err error) bool { var nc *nonCriticalError return errors.As(err, &nc) } + +// elevationError wraps errors that indicate the operation would have succeeded +// with elevated privileges (root on Unix, Administrator on Windows). +type elevationError struct { + err error +} + +func (e *elevationError) Error() string { + if e == nil || e.err == nil { + return "sysproxy: elevation required" + } + return e.err.Error() +} + +func (e *elevationError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +// RequiresElevation reports whether err indicates the operation could not +// complete because elevated privileges were needed (e.g. writing +// /etc/environment on Linux without root). Errors that satisfy this predicate +// also satisfy IsNonCritical. +func RequiresElevation(err error) bool { + if err == nil { + return false + } + var ee *elevationError + return errors.As(err, &ee) +} + +// asElevationError wraps err so that both RequiresElevation and IsNonCritical +// return true. Backends use it when a permission error blocked one step but +// the rest of the operation succeeded. +func asElevationError(err error) error { + if err == nil { + return nil + } + return &nonCriticalError{err: &elevationError{err: err}} +} diff --git a/errors_test.go b/errors_test.go index 5a5f30b..6449403 100644 --- a/errors_test.go +++ b/errors_test.go @@ -45,3 +45,60 @@ func TestNonCriticalErrorMethods(t *testing.T) { t.Fatal("nil receiver Unwrap() should return nil") } } + +func TestRequiresElevation(t *testing.T) { + if RequiresElevation(nil) { + t.Fatal("nil error must not require elevation") + } + if RequiresElevation(errors.New("plain")) { + t.Fatal("plain error must not require elevation") + } + + base := errors.New("permission denied") + err := asElevationError(base) + if !RequiresElevation(err) { + t.Fatal("elevationError should be detected") + } + if !IsNonCritical(err) { + t.Fatal("elevationError should also be non-critical") + } + if !errors.Is(err, base) { + t.Fatal("elevationError should unwrap to underlying cause") + } + + // asElevationError(nil) must be nil. + if asElevationError(nil) != nil { + t.Fatal("asElevationError(nil) should return nil") + } +} + +func TestElevationErrorMethods(t *testing.T) { + base := errors.New("permission denied") + ee := &elevationError{err: base} + if got := ee.Error(); got != "permission denied" { + t.Fatalf("Error() = %q, want %q", got, "permission denied") + } + if !errors.Is(ee, base) { + t.Fatal("elevationError.Unwrap should expose wrapped error") + } + + var nilEE *elevationError + if got := nilEE.Error(); got == "" { + t.Fatal("nil receiver Error() should return fallback message") + } + if nilEE.Unwrap() != nil { + t.Fatal("nil receiver Unwrap() should return nil") + } +} + +func TestSentinelsAreDistinct(t *testing.T) { + // Distinct instances so callers can errors.Is precisely. + for _, e := range []error{ErrProxyNotSet, ErrProxyNotEnabled, ErrUnsupportedPlatform, ErrToolMissing} { + if e == nil { + t.Fatal("sentinel error unexpectedly nil") + } + } + if errors.Is(ErrProxyNotSet, ErrProxyNotEnabled) { + t.Fatal("distinct sentinels must not compare equal") + } +} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..6875c13 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,80 @@ +package sysproxy + +import "testing" + +// FuzzParse checks that parse() never panics on arbitrary input. +func FuzzParse(f *testing.F) { + seeds := []string{ + "http://proxy.example.com:8080", + "socks5://user:pass@proxy.example.com:1080", + "", + "://bad", + "http://[::1]:8080", + "http://user@:8080", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + _, _ = parse(raw) + }) +} + +// FuzzValidateProxyURL checks that validateProxyURL never panics, and that +// any URL it accepts also parses without error. +func FuzzValidateProxyURL(f *testing.F) { + seeds := []string{ + "http://proxy.example.com:8080", + "https://proxy.example.com:443", + "socks5://proxy.example.com:1080", + "http://user:pass@proxy.example.com:8080", + "http://localhost:8080", + "://bad", + "http://", + "http://x:0", + "http://x:99999", + "", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + if err := validateProxyURL(raw); err == nil { + if _, perr := parse(raw); perr != nil { + t.Fatalf("validateProxyURL accepted %q but parse rejected it: %v", raw, perr) + } + } + }) +} + +// FuzzValidatePACURL checks that validatePACURL never panics and only accepts +// URLs with http, https, or file schemes. +func FuzzValidatePACURL(f *testing.F) { + seeds := []string{ + "http://config.example.com/proxy.pac", + "https://config.example.com/proxy.pac", + "file:///etc/proxy.pac", + "ftp://bad.example.com/proxy.pac", + "", + "http", + "://", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + err := validatePACURL(raw) + if err == nil { + ok := false + for _, p := range []string{"http://", "https://", "file://"} { + if len(raw) >= len(p) && raw[:len(p)] == p { + ok = true + break + } + } + if !ok { + t.Fatalf("validatePACURL accepted non-supported scheme %q", raw) + } + } + }) +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..9b02c4d --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,29 @@ +package buildinfo + +import ( + "strings" + "testing" +) + +func TestSummary_Defaults(t *testing.T) { + got := Summary() + for _, want := range []string{Version, "commit " + Commit, "built " + BuildDate} { + if !strings.Contains(got, want) { + t.Errorf("Summary() = %q, missing %q", got, want) + } + } +} + +func TestSummary_HonoursOverrides(t *testing.T) { + origV, origC, origD := Version, Commit, BuildDate + t.Cleanup(func() { Version, Commit, BuildDate = origV, origC, origD }) + + Version = "v1.2.3" + Commit = "deadbeef" + BuildDate = "2026-07-03" + + want := "v1.2.3 (commit deadbeef, built 2026-07-03)" + if got := Summary(); got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } +} diff --git a/kde_linux.go b/kde_linux.go new file mode 100644 index 0000000..20f46f4 --- /dev/null +++ b/kde_linux.go @@ -0,0 +1,122 @@ +//go:build linux + +package sysproxy + +import ( + "context" + "os/exec" + "strings" +) + +// kdeProxyGroup identifies the KDE kioslaverc section that stores proxy config. +const kdeProxyGroup = "Proxy Settings" + +// kreadconfigBinary returns the name of the kreadconfig tool available in PATH, +// preferring kreadconfig5 (Plasma 5) and falling back to kreadconfig6 (Plasma 6). +// Returns "" when neither is installed. +func kreadconfigBinary() string { + if isAvailable("kreadconfig5") { + return "kreadconfig5" + } + if isAvailable("kreadconfig6") { + return "kreadconfig6" + } + return "" +} + +// kreadKey runs `kreadconfig{5,6} --file kioslaverc --group "Proxy Settings" --key ` +// and returns the trimmed value. Missing keys return "" without an error. +func kreadKey(ctx context.Context, key string) string { + bin := kreadconfigBinary() + if bin == "" { + return "" + } + out, err := exec.CommandContext(normalizeContext(ctx), bin, //nolint:gosec // bin is a fixed allowlist + "--file", "kioslaverc", + "--group", kdeProxyGroup, + "--key", key).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// getGlobalKDE returns the primary (http) proxy URL from KDE's kioslaverc, or +// ErrProxyNotSet when ProxyType=0 (none) or the value is empty. ProxyType=2 +// (auto) returns the PAC script URL instead. +func getGlobalKDE(ctx context.Context) (string, error) { + pt := kreadKey(ctx, "ProxyType") + if pt == "" || pt == "0" { + return "", ErrProxyNotSet + } + if pt == "2" { + if url := kreadKey(ctx, "Proxy Config Script"); url != "" { + return url, nil + } + return "", ErrProxyNotSet + } + if url := kreadKey(ctx, "httpProxy"); url != "" { + return normalizeKDEProxyURL(url), nil + } + return "", ErrProxyNotSet +} + +// getGlobalConfigKDE returns the full per-protocol config stored in kioslaverc. +func getGlobalConfigKDE(ctx context.Context) (ProxyConfig, error) { + pt := kreadKey(ctx, "ProxyType") + if pt == "" || pt == "0" { + return ProxyConfig{}, ErrProxyNotSet + } + if pt == "2" { + pac := kreadKey(ctx, "Proxy Config Script") + if pac == "" { + return ProxyConfig{}, ErrProxyNotSet + } + return ProxyConfig{PAC: pac}, nil + } + + var cfg ProxyConfig + if u := kreadKey(ctx, "httpProxy"); u != "" { + cfg.HTTP = normalizeKDEProxyURL(u) + } + if u := kreadKey(ctx, "httpsProxy"); u != "" { + cfg.HTTPS = normalizeKDEProxyURL(u) + } + if u := kreadKey(ctx, "socksProxy"); u != "" { + cfg.SOCKS = normalizeKDESocksURL(u) + } + if v := kreadKey(ctx, "NoProxyFor"); v != "" { + cfg.NoProxy = v + } + if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { + return ProxyConfig{}, ErrProxyNotSet + } + return cfg, nil +} + +// normalizeKDEProxyURL turns a kioslaverc value into a real URL. KDE stores +// either "http://host:port" (what SetGlobal writes) or the legacy "host port" +// pair separated by a single space. +func normalizeKDEProxyURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.Contains(raw, "://") { + return raw + } + if idx := strings.LastIndex(raw, " "); idx > 0 { + return "http://" + raw[:idx] + ":" + raw[idx+1:] + } + return "http://" + raw +} + +// normalizeKDESocksURL is like normalizeKDEProxyURL but forces the socks5 +// scheme for values that lack one, matching what other backends produce. +func normalizeKDESocksURL(raw string) string { + u := normalizeKDEProxyURL(raw) + if strings.HasPrefix(u, "http://") { + return "socks5://" + strings.TrimPrefix(u, "http://") + } + return u +} diff --git a/kde_linux_test.go b/kde_linux_test.go new file mode 100644 index 0000000..6d6237d --- /dev/null +++ b/kde_linux_test.go @@ -0,0 +1,37 @@ +//go:build linux + +package sysproxy + +import "testing" + +func TestNormalizeKDEProxyURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"http://proxy.example.com:8080", "http://proxy.example.com:8080"}, + {"https://proxy.example.com:443", "https://proxy.example.com:443"}, + // Legacy KControl form: "host port" separated by space. + {"proxy.example.com 8080", "http://proxy.example.com:8080"}, + {" proxy.example.com 8080 ", "http://proxy.example.com:8080"}, + // Value without space or scheme: treat as host only, no port injection. + {"proxy.example.com", "http://proxy.example.com"}, + } + for _, c := range cases { + if got := normalizeKDEProxyURL(c.in); got != c.want { + t.Errorf("normalizeKDEProxyURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestNormalizeKDESocksURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"proxy.example.com 1080", "socks5://proxy.example.com:1080"}, + {"socks5://proxy.example.com:1080", "socks5://proxy.example.com:1080"}, + {"http://proxy.example.com:1080", "socks5://proxy.example.com:1080"}, + } + for _, c := range cases { + if got := normalizeKDESocksURL(c.in); got != c.want { + t.Errorf("normalizeKDESocksURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/sysproxy.go b/sysproxy.go index 221f6b6..495afc8 100644 --- a/sysproxy.go +++ b/sysproxy.go @@ -47,13 +47,17 @@ func (s ProxyScope) String() string { } } -// ProxyConfig holds per-protocol proxy URLs for SetMulti. -// Any field left empty is ignored. +// ProxyConfig holds per-protocol proxy URLs for SetMulti and the current +// system configuration returned by GetConfig. Any field left empty is ignored +// by SetMulti; empty fields returned by GetConfig indicate that protocol has +// no proxy configured. PAC is populated by GetConfig when the OS is in +// auto-proxy mode; SetMulti ignores it — use SetPAC to switch modes. type ProxyConfig struct { HTTP string `json:"http,omitempty"` HTTPS string `json:"https,omitempty"` SOCKS string `json:"socks,omitempty"` NoProxy string `json:"no_proxy,omitempty"` // comma-separated bypass list, e.g. "localhost,10.0.0.0/8" + PAC string `json:"pac,omitempty"` // populated by GetConfig when auto-proxy is active } // Set configures the OS system proxy to proxyURL for the given scope. @@ -229,22 +233,72 @@ func SetPACContext(ctx context.Context, pacURL string, scope ProxyScope) error { // WithProxy temporarily sets the proxy for the duration of fn, then restores // the previous proxy state (or clears it if no proxy was set before). +// The restore snapshot covers the full ProxyConfig — HTTP, HTTPS, SOCKS, +// NoProxy, and PAC — not just the HTTP field. // // err := sysproxy.WithProxy(ctx, "socks5://proxy:1080", sysproxy.ScopeGlobal, func(ctx context.Context) error { // return doRequest(ctx) // }) func WithProxy(ctx context.Context, proxyURL string, scope ProxyScope, fn func(context.Context) error) error { ctx = normalizeContext(ctx) - prev, prevErr := Get() - if err := SetContext(ctx, proxyURL, scope); err != nil { + snapshot, hadSnapshot := snapshotForRestore(scope) + + if err := SetContext(ctx, proxyURL, scope); err != nil && !IsNonCritical(err) { return err } - defer func() { - if prevErr == nil && prev != "" { - _ = Set(prev, scope) - } else { - _ = Unset(scope) - } - }() + defer restoreSnapshot(scope, snapshot, hadSnapshot) + return fn(ctx) +} + +// WithProxyMulti is the multi-protocol variant of WithProxy: it applies cfg +// via SetMulti and restores the previous full configuration on return. +func WithProxyMulti(ctx context.Context, cfg ProxyConfig, scope ProxyScope, fn func(context.Context) error) error { + ctx = normalizeContext(ctx) + snapshot, hadSnapshot := snapshotForRestore(scope) + + if err := SetMultiContext(ctx, cfg, scope); err != nil && !IsNonCritical(err) { + return err + } + defer restoreSnapshot(scope, snapshot, hadSnapshot) return fn(ctx) } + +// snapshotForRestore reads the current global config so WithProxy* can undo +// its change. ScopeShell/ScopeUser have no read-back path, so the caller +// restores by Unset. +func snapshotForRestore(scope ProxyScope) (ProxyConfig, bool) { + if scope != ScopeGlobal { + return ProxyConfig{}, false + } + cfg, err := GetConfig() + if err != nil { + return ProxyConfig{}, false + } + return cfg, true +} + +// restoreSnapshot re-applies snap after WithProxy / WithProxyMulti finishes. +// Restore errors are logged and swallowed so they do not shadow fn's error. +func restoreSnapshot(scope ProxyScope, snap ProxyConfig, had bool) { + if !had { + if err := Unset(scope); err != nil && !IsNonCritical(err) { + logf("WithProxy: restore Unset failed: %v", err) + } + return + } + if snap.PAC != "" { + if err := SetPAC(snap.PAC, scope); err != nil && !IsNonCritical(err) { + logf("WithProxy: restore SetPAC failed: %v", err) + } + return + } + if snap.HTTP == "" && snap.HTTPS == "" && snap.SOCKS == "" { + if err := Unset(scope); err != nil && !IsNonCritical(err) { + logf("WithProxy: restore Unset failed: %v", err) + } + return + } + if err := SetMulti(snap, scope); err != nil && !IsNonCritical(err) { + logf("WithProxy: restore SetMulti failed: %v", err) + } +} diff --git a/sysproxy_darwin.go b/sysproxy_darwin.go index 353e734..8aaa14e 100644 --- a/sysproxy_darwin.go +++ b/sysproxy_darwin.go @@ -72,7 +72,8 @@ func getGlobal(ctx context.Context) (string, error) { if err != nil || len(services) == 0 { return "", fmt.Errorf("sysproxy: no network services found") } - out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-getwebproxy", services[0]).Output() //nolint:gosec + svc := services[0] + out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-getwebproxy", svc).Output() //nolint:gosec if err != nil { return "", err } @@ -80,7 +81,41 @@ func getGlobal(ctx context.Context) (string, error) { if ok && h != "" && p != "0" { return "http://" + h + ":" + p, nil } - return "", fmt.Errorf("sysproxy: proxy not set") + if pac, ok := readMacPAC(ctx, svc); ok { + return pac, nil + } + return "", ErrProxyNotSet +} + +// readMacPAC returns the auto-proxy (PAC) URL for a network service when +// auto-proxy is on. The second return value is false when auto-proxy is off +// or the URL is empty. +func readMacPAC(ctx context.Context, svc string) (string, bool) { + out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-getautoproxyurl", svc).Output() //nolint:gosec + if err != nil { + return "", false + } + return parseAutoProxyOutput(string(out)) +} + +// parseAutoProxyOutput extracts a PAC URL from `networksetup -getautoproxyurl` +// output. It returns ok=false when auto-proxy is disabled or the URL is a +// placeholder (empty, "(null)"). +func parseAutoProxyOutput(out string) (string, bool) { + var url string + var enabled bool + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "URL:"): + url = strings.TrimSpace(strings.TrimPrefix(line, "URL:")) + case strings.HasPrefix(line, "Enabled: Yes"): + enabled = true + } + } + if !enabled || url == "" || url == "(null)" { + return "", false + } + return url, true } func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { @@ -93,18 +128,19 @@ func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { var cfg ProxyConfig for _, q := range []struct { - flag string - dest *string + flag string + scheme string + dest *string }{ - {"-getwebproxy", &cfg.HTTP}, - {"-getsecurewebproxy", &cfg.HTTPS}, - {"-getsocksfirewallproxy", &cfg.SOCKS}, + {"-getwebproxy", "http", &cfg.HTTP}, + {"-getsecurewebproxy", "http", &cfg.HTTPS}, + {"-getsocksfirewallproxy", "socks5", &cfg.SOCKS}, } { out, err := exec.CommandContext(ctx, "networksetup", q.flag, svc).Output() //nolint:gosec if err == nil { h, p, ok := parseNSProxyOutput(string(out)) if ok && h != "" && p != "0" { - *q.dest = "http://" + h + ":" + p + *q.dest = q.scheme + "://" + h + ":" + p } } } @@ -120,8 +156,12 @@ func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { cfg.NoProxy = strings.Join(parts, ",") } - if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { - return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + if pac, ok := readMacPAC(ctx, svc); ok { + cfg.PAC = pac + } + + if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" && cfg.PAC == "" { + return ProxyConfig{}, ErrProxyNotSet } return cfg, nil } diff --git a/sysproxy_darwin_test.go b/sysproxy_darwin_test.go index 6abe467..a78f866 100644 --- a/sysproxy_darwin_test.go +++ b/sysproxy_darwin_test.go @@ -4,6 +4,43 @@ package sysproxy import "testing" +func TestParseAutoProxyOutput(t *testing.T) { + cases := []struct { + name string + input string + wantURL string + wantOK bool + }{ + { + name: "enabled with URL", + input: "URL: https://config.example.com/proxy.pac\nEnabled: Yes\n", + wantURL: "https://config.example.com/proxy.pac", + wantOK: true, + }, + { + name: "disabled", + input: "URL: https://config.example.com/proxy.pac\nEnabled: No\n", + }, + { + name: "(null) placeholder", + input: "URL: (null)\nEnabled: Yes\n", + }, + { + name: "empty", + input: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + url, ok := parseAutoProxyOutput(c.input) + if url != c.wantURL || ok != c.wantOK { + t.Errorf("parseAutoProxyOutput(%q) = (%q, %v), want (%q, %v)", + c.input, url, ok, c.wantURL, c.wantOK) + } + }) + } +} + func TestParseNSProxyOutput(t *testing.T) { cases := []struct { name string diff --git a/sysproxy_linux.go b/sysproxy_linux.go index 84576dc..1d453ea 100644 --- a/sysproxy_linux.go +++ b/sysproxy_linux.go @@ -4,6 +4,7 @@ package sysproxy import ( "context" + "errors" "fmt" "os" "os/exec" @@ -36,6 +37,9 @@ func setGlobal(ctx context.Context, p *proxy) error { _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", p.rawURL) } if err := writeEtcEnvironment("/etc/environment", p.rawURL); err != nil { + if errors.Is(err, os.ErrPermission) { + return asElevationError(err) + } return &nonCriticalError{err: err} } return nil @@ -49,18 +53,40 @@ func unsetGlobal(ctx context.Context) error { _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") } if err := clearEtcEnvironment("/etc/environment"); err != nil { + if errors.Is(err, os.ErrPermission) { + return asElevationError(err) + } return &nonCriticalError{err: err} } return nil } func getGlobal(ctx context.Context) (string, error) { - if !isAvailable("gsettings") { - return "", fmt.Errorf("sysproxy: gsettings not available") + if isAvailable("gsettings") { + if url, err := getGlobalGnome(ctx); err == nil { + return url, nil + } else if !errors.Is(err, ErrProxyNotSet) { + return "", err + } + } + if isAvailable("kreadconfig5") || isAvailable("kreadconfig6") { + if url, err := getGlobalKDE(ctx); err == nil { + return url, nil + } else if !errors.Is(err, ErrProxyNotSet) { + return "", err + } } + return "", ErrProxyNotSet +} + +func getGlobalGnome(ctx context.Context) (string, error) { out, err := exec.CommandContext(normalizeContext(ctx), "gsettings", "get", "org.gnome.system.proxy", "mode").Output() - if err != nil || !strings.Contains(string(out), "manual") { - return "", fmt.Errorf("sysproxy: proxy not set") + if err != nil { + return "", fmt.Errorf("sysproxy: gsettings mode: %w", err) + } + mode := strings.Trim(strings.TrimSpace(string(out)), "'") + if mode != "manual" { + return "", ErrProxyNotSet } host, err1 := exec.CommandContext(normalizeContext(ctx), "gsettings", "get", "org.gnome.system.proxy.http", "host").Output() port, err2 := exec.CommandContext(normalizeContext(ctx), "gsettings", "get", "org.gnome.system.proxy.http", "port").Output() @@ -70,7 +96,7 @@ func getGlobal(ctx context.Context) (string, error) { h := strings.Trim(strings.TrimSpace(string(host)), "'") p := strings.TrimSpace(string(port)) if h == "" || p == "0" { - return "", fmt.Errorf("sysproxy: proxy not set") + return "", ErrProxyNotSet } return "http://" + h + ":" + p, nil } @@ -97,11 +123,40 @@ func parseGsettingsArray(raw string) string { } func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { - if !isAvailable("gsettings") { - return ProxyConfig{}, fmt.Errorf("sysproxy: gsettings not available") + if isAvailable("gsettings") { + cfg, err := getGlobalConfigGnome(ctx) + if err == nil { + return cfg, nil + } + if !errors.Is(err, ErrProxyNotSet) { + return ProxyConfig{}, err + } } - if gsettingsField(ctx, "org.gnome.system.proxy", "mode") != "manual" { - return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + if isAvailable("kreadconfig5") || isAvailable("kreadconfig6") { + cfg, err := getGlobalConfigKDE(ctx) + if err == nil { + return cfg, nil + } + if !errors.Is(err, ErrProxyNotSet) { + return ProxyConfig{}, err + } + } + return ProxyConfig{}, ErrProxyNotSet +} + +func getGlobalConfigGnome(ctx context.Context) (ProxyConfig, error) { + mode := gsettingsField(ctx, "org.gnome.system.proxy", "mode") + switch mode { + case "auto": + pac := gsettingsField(ctx, "org.gnome.system.proxy", "autoconfig-url") + if pac == "" { + return ProxyConfig{}, ErrProxyNotSet + } + return ProxyConfig{PAC: pac}, nil + case "manual": + // handled below + default: + return ProxyConfig{}, ErrProxyNotSet } var cfg ProxyConfig @@ -125,7 +180,7 @@ func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { cfg.NoProxy = parseGsettingsArray(string(raw)) if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { - return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + return ProxyConfig{}, ErrProxyNotSet } return cfg, nil } diff --git a/sysproxy_other.go b/sysproxy_other.go index 6c635ee..031293b 100644 --- a/sysproxy_other.go +++ b/sysproxy_other.go @@ -8,6 +8,9 @@ import ( "runtime" ) +// errUnsupported wraps ErrUnsupportedPlatform with the current GOOS so callers +// can use errors.Is while still getting a specific message. + func setUser(_ string) error { return errUnsupported() } func unsetUser() error { return errUnsupported() } func setUserPAC(_ string) error { return errUnsupported() } @@ -28,5 +31,5 @@ func (otherBackend) SetGlobalMulti(_ context.Context, _ ProxyConfig) error { ret func init() { activeBackend = otherBackend{} } func errUnsupported() error { - return fmt.Errorf("sysproxy: unsupported OS %q", runtime.GOOS) + return fmt.Errorf("%w %q", ErrUnsupportedPlatform, runtime.GOOS) } diff --git a/sysproxy_test.go b/sysproxy_test.go index 76a3375..8b7c16c 100644 --- a/sysproxy_test.go +++ b/sysproxy_test.go @@ -418,17 +418,26 @@ func TestSetPACGlobal_CallsBackend(t *testing.T) { } func TestWithProxy_RestoresPrevious(t *testing.T) { - const prev = "http://prev.example.com:8080" + prev := ProxyConfig{ + HTTP: "http://prev.example.com:8080", + HTTPS: "http://prev.example.com:8080", + SOCKS: "socks5://prev.example.com:1080", + } const next = "http://next.example.com:9090" setLog := []string{} + multiLog := []ProxyConfig{} useMockBackend(t, &mockBackend{ setGlobalFn: func(_ context.Context, p *proxy) error { setLog = append(setLog, p.rawURL) return nil }, - unsetGlobalFn: func(_ context.Context) error { return nil }, - getGlobalFn: func(_ context.Context) (string, error) { return prev, nil }, + setGlobalMultiFn: func(_ context.Context, cfg ProxyConfig) error { + multiLog = append(multiLog, cfg) + return nil + }, + unsetGlobalFn: func(_ context.Context) error { return nil }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return prev, nil }, }) t.Cleanup(unsetEnvVars) @@ -438,12 +447,121 @@ func TestWithProxy_RestoresPrevious(t *testing.T) { if err != nil { t.Fatal(err) } - if len(setLog) < 2 { - t.Fatalf("expected at least 2 Set calls, got %d", len(setLog)) + if len(setLog) != 1 || setLog[0] != next { + t.Fatalf("expected exactly one SetGlobal(%q), got %v", next, setLog) + } + if len(multiLog) != 1 || multiLog[0] != prev { + t.Fatalf("expected restore SetMulti(%+v), got %+v", prev, multiLog) + } +} + +func TestWithProxy_UnsetsWhenNoPrevious(t *testing.T) { + unsetCalled := 0 + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, _ *proxy) error { return nil }, + unsetGlobalFn: func(_ context.Context) error { unsetCalled++; return nil }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return ProxyConfig{}, ErrProxyNotSet }, + }) + t.Cleanup(unsetEnvVars) + + err := WithProxy(context.Background(), "http://next:9090", ScopeGlobal, func(_ context.Context) error { + return nil + }) + if err != nil { + t.Fatal(err) + } + if unsetCalled != 1 { + t.Fatalf("expected exactly one Unset, got %d", unsetCalled) + } +} + +func TestWithProxyMulti_RestoresPAC(t *testing.T) { + pacURL := "http://config.example.com/proxy.pac" + prev := ProxyConfig{PAC: pacURL} + next := ProxyConfig{HTTP: "http://next.example.com:8080"} + + pacRestored := "" + useMockBackend(t, &mockBackend{ + setGlobalMultiFn: func(_ context.Context, _ ProxyConfig) error { return nil }, + setGlobalPACFn: func(_ context.Context, u string) error { pacRestored = u; return nil }, + unsetGlobalFn: func(_ context.Context) error { return nil }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return prev, nil }, + }) + t.Cleanup(unsetEnvVars) + + err := WithProxyMulti(context.Background(), next, ScopeGlobal, func(_ context.Context) error { + return nil + }) + if err != nil { + t.Fatal(err) + } + if pacRestored != pacURL { + t.Fatalf("expected PAC %q to be restored, got %q", pacURL, pacRestored) + } +} + +func TestWithProxy_RestoresMultiWhenSnapshotHasProtocols(t *testing.T) { + prev := ProxyConfig{ + HTTP: "http://prev.example.com:8080", + HTTPS: "http://prev.example.com:8443", } - // last Set call should restore the previous proxy - if setLog[len(setLog)-1] != prev { - t.Errorf("last Set = %q, want %q", setLog[len(setLog)-1], prev) + restored := ProxyConfig{} + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, _ *proxy) error { return nil }, + setGlobalMultiFn: func(_ context.Context, cfg ProxyConfig) error { restored = cfg; return nil }, + unsetGlobalFn: func(_ context.Context) error { + t.Fatal("Unset should not be called when snapshot has state") + return nil + }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return prev, nil }, + }) + t.Cleanup(unsetEnvVars) + + err := WithProxy(context.Background(), "http://next:9090", ScopeGlobal, func(_ context.Context) error { + return nil + }) + if err != nil { + t.Fatal(err) + } + if restored != prev { + t.Fatalf("SetMulti restore = %+v, want %+v", restored, prev) + } +} + +func TestWithProxyMulti_UnsetsWhenNoPrevious(t *testing.T) { + unsetCalled := 0 + useMockBackend(t, &mockBackend{ + setGlobalMultiFn: func(_ context.Context, _ ProxyConfig) error { return nil }, + unsetGlobalFn: func(_ context.Context) error { unsetCalled++; return nil }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return ProxyConfig{}, ErrProxyNotSet }, + }) + t.Cleanup(unsetEnvVars) + + err := WithProxyMulti(context.Background(), ProxyConfig{HTTP: "http://x:8080"}, ScopeGlobal, func(_ context.Context) error { + return nil + }) + if err != nil { + t.Fatal(err) + } + if unsetCalled != 1 { + t.Fatalf("expected exactly one Unset, got %d", unsetCalled) + } +} + +func TestWithProxy_PropagatesFnError(t *testing.T) { + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, _ *proxy) error { return nil }, + unsetGlobalFn: func(_ context.Context) error { return nil }, + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return ProxyConfig{}, ErrProxyNotSet }, + }) + t.Cleanup(unsetEnvVars) + + sentinel := errors.New("boom") + err := WithProxy(context.Background(), "http://x:8080", ScopeGlobal, func(_ context.Context) error { + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) } } diff --git a/sysproxy_windows.go b/sysproxy_windows.go index 2784bdb..20c347d 100644 --- a/sysproxy_windows.go +++ b/sysproxy_windows.go @@ -72,12 +72,22 @@ func currentProxyHost(ctx context.Context) (string, error) { func getGlobal(ctx context.Context) (string, error) { out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyEnable").Output() - if err != nil || !strings.Contains(string(out), "0x1") { - return "", fmt.Errorf("sysproxy: proxy not enabled") + if err != nil { + // ProxyEnable missing → check PAC + if pac, perr := readAutoConfigURL(ctx); perr == nil && pac != "" { + return pac, nil + } + return "", ErrProxyNotEnabled + } + if !strings.Contains(string(out), "0x1") { + if pac, perr := readAutoConfigURL(ctx); perr == nil && pac != "" { + return pac, nil + } + return "", ErrProxyNotEnabled } out, err = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyServer").Output() if err != nil { - return "", fmt.Errorf("sysproxy: cannot read ProxyServer") + return "", fmt.Errorf("sysproxy: cannot read ProxyServer: %w", err) } for _, line := range strings.Split(string(out), "\n") { if strings.Contains(line, "ProxyServer") { @@ -87,7 +97,17 @@ func getGlobal(ctx context.Context) (string, error) { } } } - return "", fmt.Errorf("sysproxy: proxy not set") + return "", ErrProxyNotSet +} + +// readAutoConfigURL reads AutoConfigURL from the registry. A missing value +// returns "" and nil so callers can distinguish "no PAC" from a real error. +func readAutoConfigURL(ctx context.Context) (string, error) { + out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "AutoConfigURL").Output() + if err != nil { + return "", nil //nolint:nilerr + } + return extractRegValue(string(out), "AutoConfigURL"), nil } func setGlobalPAC(ctx context.Context, pacURL string) error { @@ -118,63 +138,29 @@ func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { return nil } -// parseWindowsProxyServer converts a ProxyServer registry value to ProxyConfig. -// The value is either "host:port" (single proxy for all protocols) or -// "http=h:p;https=h:p;socks=h:p" (per-protocol). -func parseWindowsProxyServer(server string) ProxyConfig { - var cfg ProxyConfig - if !strings.Contains(server, "=") { - cfg.HTTP = "http://" + server - cfg.HTTPS = "http://" + server - cfg.SOCKS = "socks5://" + server - return cfg - } - for _, part := range strings.Split(server, ";") { - kv := strings.SplitN(part, "=", 2) - if len(kv) != 2 { - continue - } - k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) - switch k { - case "http": - cfg.HTTP = "http://" + v - case "https": - cfg.HTTPS = "http://" + v - case "socks": - cfg.SOCKS = "socks5://" + v - } - } - return cfg -} +func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { + pac, _ := readAutoConfigURL(ctx) -// extractRegValue returns the last whitespace-separated field on the line -// containing key in reg.exe output. -func extractRegValue(output, key string) string { - for _, line := range strings.Split(output, "\n") { - if strings.Contains(line, key) { - parts := strings.Fields(line) - if len(parts) > 0 { - return strings.TrimSpace(parts[len(parts)-1]) - } + out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyEnable").Output() + proxyEnabled := err == nil && strings.Contains(string(out), "0x1") + + if !proxyEnabled { + if pac != "" { + return ProxyConfig{PAC: pac}, nil } + return ProxyConfig{}, ErrProxyNotEnabled } - return "" -} -func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { - out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyEnable").Output() - if err != nil || !strings.Contains(string(out), "0x1") { - return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not enabled") - } out, err = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyServer").Output() if err != nil { - return ProxyConfig{}, fmt.Errorf("sysproxy: cannot read ProxyServer") + return ProxyConfig{}, fmt.Errorf("sysproxy: cannot read ProxyServer: %w", err) } server := extractRegValue(string(out), "ProxyServer") if server == "" { - return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + return ProxyConfig{}, ErrProxyNotSet } cfg := parseWindowsProxyServer(server) + cfg.PAC = pac out, _ = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyOverride").Output() cfg.NoProxy = extractRegValue(string(out), "ProxyOverride") diff --git a/windows_parse.go b/windows_parse.go new file mode 100644 index 0000000..5f5fdbf --- /dev/null +++ b/windows_parse.go @@ -0,0 +1,46 @@ +package sysproxy + +import "strings" + +// parseWindowsProxyServer converts a ProxyServer registry value to ProxyConfig. +// The value is either "host:port" (single proxy for all protocols) or +// "http=h:p;https=h:p;socks=h:p" (per-protocol). +func parseWindowsProxyServer(server string) ProxyConfig { + var cfg ProxyConfig + if !strings.Contains(server, "=") { + cfg.HTTP = "http://" + server + cfg.HTTPS = "http://" + server + cfg.SOCKS = "socks5://" + server + return cfg + } + for _, part := range strings.Split(server, ";") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + switch k { + case "http": + cfg.HTTP = "http://" + v + case "https": + cfg.HTTPS = "http://" + v + case "socks": + cfg.SOCKS = "socks5://" + v + } + } + return cfg +} + +// extractRegValue returns the last whitespace-separated field on the line +// containing key in reg.exe output. +func extractRegValue(output, key string) string { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, key) { + parts := strings.Fields(line) + if len(parts) > 0 { + return strings.TrimSpace(parts[len(parts)-1]) + } + } + } + return "" +} diff --git a/windows_parse_test.go b/windows_parse_test.go new file mode 100644 index 0000000..e7715f4 --- /dev/null +++ b/windows_parse_test.go @@ -0,0 +1,46 @@ +package sysproxy + +import "testing" + +func TestParseWindowsProxyServer_Single(t *testing.T) { + cfg := parseWindowsProxyServer("proxy.example.com:8080") + if cfg.HTTP != "http://proxy.example.com:8080" || + cfg.HTTPS != "http://proxy.example.com:8080" || + cfg.SOCKS != "socks5://proxy.example.com:8080" { + t.Errorf("single-value parse mismatch: %+v", cfg) + } +} + +func TestParseWindowsProxyServer_PerProtocol(t *testing.T) { + cfg := parseWindowsProxyServer("http=h.example.com:8080;https=s.example.com:8443;socks=x.example.com:1080") + if cfg.HTTP != "http://h.example.com:8080" { + t.Errorf("HTTP = %q", cfg.HTTP) + } + if cfg.HTTPS != "http://s.example.com:8443" { + t.Errorf("HTTPS = %q", cfg.HTTPS) + } + if cfg.SOCKS != "socks5://x.example.com:1080" { + t.Errorf("SOCKS = %q", cfg.SOCKS) + } +} + +func TestParseWindowsProxyServer_MalformedPart(t *testing.T) { + // Garbage segments must be skipped without panicking. + cfg := parseWindowsProxyServer("http=h:8080;=nope;unknown=1;https=s:8443") + if cfg.HTTP != "http://h:8080" || cfg.HTTPS != "http://s:8443" { + t.Errorf("unexpected cfg: %+v", cfg) + } +} + +func TestExtractRegValue(t *testing.T) { + out := ` +HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings + ProxyServer REG_SZ proxy.example.com:8080 +` + if got := extractRegValue(out, "ProxyServer"); got != "proxy.example.com:8080" { + t.Errorf("extractRegValue = %q", got) + } + if got := extractRegValue(out, "Missing"); got != "" { + t.Errorf("expected empty for missing key, got %q", got) + } +}