Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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) | ✓ | ✓ | ✓ | ✓ |
Expand Down
8 changes: 4 additions & 4 deletions appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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")
Expand Down
17 changes: 9 additions & 8 deletions appconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sysproxy

import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -535,26 +536,26 @@ 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)
}
}

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

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

Expand Down
64 changes: 64 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}
}
57 changes: 57 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
80 changes: 80 additions & 0 deletions fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
Loading
Loading