From 3862d0a83e2f0abd6b18b3325ff02d6e9dd2ed49 Mon Sep 17 00:00:00 2001 From: Marcin <120790937+mar0ls@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:16:58 +0200 Subject: [PATCH 1/3] Add CI matrix: test on ubuntu/macos/windows, lint separate, ci gate job --- .github/workflows/test.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24d3518..567fc5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,19 +8,35 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/setup-go@v5 with: go-version: '1.22' - name: Install dependencies run: go mod tidy - name: Run tests run: go test -v ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 with: version: v2.11.4 args: --timeout=5m + + ci: + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - run: echo "All checks passed" From 226f06f399ecb9513e275fe5e26ad9a49dc8a68c Mon Sep 17 00:00:00 2001 From: Marcin <120790937+mar0ls@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:43:21 +0200 Subject: [PATCH 2/3] context-aware API variants and fix cross-platform app config testscleaning golancilint err --- README.md | 44 ++++- appconfig.go | 61 ++++-- appconfig_test.go | 446 ++++++++++++++++++++++++++++++++++++++++++++ check_test.go | 91 +++++++++ context_api_test.go | 104 +++++++++++ context_helpers.go | 10 + coverage.out | 302 ++++++++++++++++++++++++++++++ example_test.go | 20 ++ sysproxy.go | 64 ++++++- sysproxy_darwin.go | 73 ++++---- sysproxy_linux.go | 85 ++++----- sysproxy_other.go | 21 ++- sysproxy_test.go | 34 ++-- sysproxy_windows.go | 61 +++--- 14 files changed, 1255 insertions(+), 161 deletions(-) create mode 100644 appconfig_test.go create mode 100644 check_test.go create mode 100644 context_api_test.go create mode 100644 context_helpers.go create mode 100644 coverage.out diff --git a/README.md b/README.md index 86ab7fe..4e3f069 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,20 @@ Cross-platform system proxy management for Go — set, clear, and query the OS proxy from your application without shell scripts. ```go -sysproxy.Set("socks5://user:pass@proxy.example.com:1080", sysproxy.ScopeGlobal) -sysproxy.Unset(sysproxy.ScopeGlobal) +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +if err := sysproxy.SetContext(ctx, "socks5://user:pass@proxy.example.com:1080", sysproxy.ScopeGlobal); err != nil { + log.Fatal(err) +} +defer sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal) ``` ## Why Proxy-switching tools, VPN clients, and network-aware CLIs built in Go often need to set the OS system proxy — not just read it. The existing options are either buried inside a large SDK ([outline-sdk/x/sysproxy](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/x/sysproxy)), Windows-only, or rely on shipping pre-built binaries. -`go-sysproxy` is a focused, standalone package: macOS (`networksetup`), Linux (GNOME + KDE + `/etc/environment`), and Windows (registry + Credential Manager), with health checking, per-app config, and temporary proxy restore — zero external dependencies. +`go-sysproxy` is a focused package for macOS (`networksetup`), Linux (GNOME + KDE + `/etc/environment`), and Windows (registry + Credential Manager). It covers system proxy changes, health checks, per-app config, and temporary proxy restore without external dependencies. ## Installation @@ -39,18 +44,19 @@ import ( ) func main() { - // Verify the proxy is reachable before committing ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + + // Verify the proxy is reachable before committing if err := sysproxy.Check(ctx, "http://proxy.example.com:8080"); err != nil { log.Fatal(err) } - // Set system proxy globally - if err := sysproxy.Set("http://proxy.example.com:8080", sysproxy.ScopeGlobal); err != nil { + // Apply the system proxy and restore it on exit + if err := sysproxy.SetContext(ctx, "http://proxy.example.com:8080", sysproxy.ScopeGlobal); err != nil { log.Fatal(err) } - defer sysproxy.Unset(sysproxy.ScopeGlobal) + defer sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal) } ``` @@ -73,6 +79,18 @@ err = sysproxy.Unset(sysproxy.ScopeGlobal) url, err := sysproxy.Get() // reads current system proxy ``` +The plain wrappers stay available for backward compatibility. If you want cancellation and deadlines, use the context-aware variants: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +err := sysproxy.SetContext(ctx, "http://user:pass@proxy.example.com:8080", sysproxy.ScopeGlobal) +err = sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal) + +url, err := sysproxy.GetContext(ctx) +``` + ### Per-protocol proxy ```go @@ -84,12 +102,16 @@ err := sysproxy.SetMulti(sysproxy.ProxyConfig{ }, sysproxy.ScopeGlobal) ``` +`SetMultiContext` is also available when you want the same API with cancellation support. + ### PAC file ```go err := sysproxy.SetPAC("https://config.example.com/proxy.pac", sysproxy.ScopeGlobal) ``` +`SetPACContext` is also available for deadline-aware callers. + ### Temporary proxy `WithProxy` sets the proxy for the duration of `fn` and restores the previous state on return — even if `fn` returns an error. @@ -130,6 +152,8 @@ sysproxy.WriteAppConfig(sysproxy.AppWget, "http://proxy.example.com:8080") // ~/ sysproxy.ClearAppConfig(sysproxy.AppGit) ``` +`WriteAppConfigContext` and `ClearAppConfigContext` are available for `git` and `npm`, where configuration is applied through external commands. + ### Logging / auditing ```go @@ -164,6 +188,12 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr > Credentials in proxy URLs are handled by the OS — on Windows they are stored in Credential Manager, not written to disk in plaintext. +## Notes + +- `Check` verifies TCP reachability of the proxy endpoint. It does not validate credentials or perform a protocol-level handshake. +- Context-aware APIs abort before starting side effects when the context is already canceled, and command-backed operations use `exec.CommandContext`. +- `ScopeGlobal` may still require elevated permissions depending on the platform and the target settings store. + ## Platform support | Feature | macOS | Linux (GNOME) | Linux (KDE) | Windows | diff --git a/appconfig.go b/appconfig.go index 2c9c70a..c27ac80 100644 --- a/appconfig.go +++ b/appconfig.go @@ -2,6 +2,7 @@ package sysproxy import ( "bufio" + "context" "fmt" "os" "os/exec" @@ -26,17 +27,27 @@ const ( // // err := sysproxy.WriteAppConfig(sysproxy.AppGit, "http://proxy.example.com:8080") func WriteAppConfig(app AppName, proxyURL string) error { + return WriteAppConfigContext(context.Background(), app, proxyURL) +} + +// WriteAppConfigContext writes proxy settings to the tool-specific config for +// app. It aborts before side effects if ctx is already canceled. +func WriteAppConfigContext(ctx context.Context, app AppName, proxyURL string) error { if err := validateProxyURL(proxyURL); err != nil { return err } + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } var err error switch app { case AppCurl: err = writeCurlRC(proxyURL) case AppGit: - err = writeGitProxy(proxyURL) + err = writeGitProxy(ctx, proxyURL) case AppNPM: - err = writeNPMProxy(proxyURL) + err = writeNPMProxy(ctx, proxyURL) case AppPip: err = writePipConf(proxyURL) case AppWget: @@ -50,14 +61,24 @@ func WriteAppConfig(app AppName, proxyURL string) error { // ClearAppConfig removes proxy settings from the tool-specific config for app. func ClearAppConfig(app AppName) error { + return ClearAppConfigContext(context.Background(), app) +} + +// ClearAppConfigContext removes proxy settings from the tool-specific config +// for app. It aborts before side effects if ctx is already canceled. +func ClearAppConfigContext(ctx context.Context, app AppName) error { + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } var err error switch app { case AppCurl: err = clearCurlRC() case AppGit: - err = clearGitProxy() + err = clearGitProxy(ctx) case AppNPM: - err = clearNPMProxy() + err = clearNPMProxy(ctx) case AppPip: err = clearPipConf() case AppWget: @@ -89,57 +110,57 @@ func clearCurlRC() error { // ── git (git config --global) ───────────────────────────────────────────────── -func runGit(args ...string) error { - return exec.Command("git", args...).Run() //nolint:noctx,gosec +func runGit(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "git", args...).Run() //nolint:gosec } -func writeGitProxy(proxyURL string) error { +func writeGitProxy(ctx context.Context, proxyURL string) error { if !isAvailable("git") { return fmt.Errorf("sysproxy: git not found in PATH") } - if err := runGit("config", "--global", "http.proxy", proxyURL); err != nil { + if err := runGit(ctx, "config", "--global", "http.proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: git config http.proxy: %w", err) } - if err := runGit("config", "--global", "https.proxy", proxyURL); err != nil { + if err := runGit(ctx, "config", "--global", "https.proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: git config https.proxy: %w", err) } return nil } -func clearGitProxy() error { +func clearGitProxy(ctx context.Context) error { if !isAvailable("git") { return fmt.Errorf("sysproxy: git not found in PATH") } - _ = runGit("config", "--global", "--unset", "http.proxy") - _ = runGit("config", "--global", "--unset", "https.proxy") + _ = runGit(ctx, "config", "--global", "--unset", "http.proxy") + _ = runGit(ctx, "config", "--global", "--unset", "https.proxy") return nil } // ── npm (npm config set) ────────────────────────────────────────────────────── -func runNPM(args ...string) error { - return exec.Command("npm", args...).Run() //nolint:noctx,gosec +func runNPM(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "npm", args...).Run() //nolint:gosec } -func writeNPMProxy(proxyURL string) error { +func writeNPMProxy(ctx context.Context, proxyURL string) error { if !isAvailable("npm") { return fmt.Errorf("sysproxy: npm not found in PATH") } - if err := runNPM("config", "set", "proxy", proxyURL); err != nil { + if err := runNPM(ctx, "config", "set", "proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: npm config set proxy: %w", err) } - if err := runNPM("config", "set", "https-proxy", proxyURL); err != nil { + if err := runNPM(ctx, "config", "set", "https-proxy", proxyURL); err != nil { return fmt.Errorf("sysproxy: npm config set https-proxy: %w", err) } return nil } -func clearNPMProxy() error { +func clearNPMProxy(ctx context.Context) error { if !isAvailable("npm") { return fmt.Errorf("sysproxy: npm not found in PATH") } - _ = runNPM("config", "delete", "proxy") - _ = runNPM("config", "delete", "https-proxy") + _ = runNPM(ctx, "config", "delete", "proxy") + _ = runNPM(ctx, "config", "delete", "https-proxy") return nil } diff --git a/appconfig_test.go b/appconfig_test.go new file mode 100644 index 0000000..6678939 --- /dev/null +++ b/appconfig_test.go @@ -0,0 +1,446 @@ +package sysproxy + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func installFakeCommand(t *testing.T, name string) string { + t.Helper() + + dir := t.TempDir() + logPath := filepath.Join(dir, name+".log") + + var ( + scriptPath string + content string + ) + + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, name+".bat") + content = "@echo off\r\n" + + "if not \"%SYSPROXY_TEST_FAIL_ON%\"==\"\" (\r\n" + + " echo %* | findstr /C:\"%SYSPROXY_TEST_FAIL_ON%\" >nul && exit /b 1\r\n" + + ")\r\n" + + "echo %*>>\"%SYSPROXY_TEST_LOG%\"\r\n" + } else { + scriptPath = filepath.Join(dir, name) + content = "#!/bin/sh\n" + + "if [ -n \"$SYSPROXY_TEST_FAIL_ON\" ]; then\n" + + " case \"$*\" in\n" + + " *\"$SYSPROXY_TEST_FAIL_ON\"*) exit 1 ;;\n" + + " esac\n" + + "fi\n" + + "printf '%s\\n' \"$*\" >> \"$SYSPROXY_TEST_LOG\"\n" + } + + if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil { //nolint:gosec + t.Fatal(err) + } + + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("SYSPROXY_TEST_LOG", logPath) + t.Setenv("SYSPROXY_TEST_FAIL_ON", "") + + return logPath +} + +func readCommandLog(t *testing.T, path string) []string { + t.Helper() + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatal(err) + } + + text := strings.TrimSpace(string(data)) + if text == "" { + return nil + } + + return strings.Split(text, "\n") +} + +func TestWriteGitProxy(t *testing.T) { + logPath := installFakeCommand(t, "git") + + if err := writeGitProxy(context.Background(), "http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + want := []string{ + "config --global http.proxy http://proxy.example.com:8080", + "config --global https.proxy http://proxy.example.com:8080", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("git commands = %q, want %q", lines, want) + } +} + +func TestWriteAppConfigGit(t *testing.T) { + logPath := installFakeCommand(t, "git") + + if err := WriteAppConfig(AppGit, "http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + if len(lines) != 2 { + t.Fatalf("expected two git config calls, got %q", lines) + } +} + +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) + } +} + +func TestWriteGitProxyCommandFailure(t *testing.T) { + _ = installFakeCommand(t, "git") + t.Setenv("SYSPROXY_TEST_FAIL_ON", "http.proxy") + + err := writeGitProxy(context.Background(), "http://proxy.example.com:8080") + if err == nil || !strings.Contains(err.Error(), "git config http.proxy") { + t.Fatalf("expected wrapped git command error, got %v", err) + } +} + +func TestClearGitProxy(t *testing.T) { + logPath := installFakeCommand(t, "git") + + if err := clearGitProxy(context.Background()); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + want := []string{ + "config --global --unset http.proxy", + "config --global --unset https.proxy", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("git commands = %q, want %q", lines, want) + } +} + +func TestClearAppConfigGit(t *testing.T) { + logPath := installFakeCommand(t, "git") + + if err := ClearAppConfig(AppGit); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + if len(lines) != 2 { + t.Fatalf("expected two git unset calls, got %q", lines) + } +} + +func TestWriteNPMProxy(t *testing.T) { + logPath := installFakeCommand(t, "npm") + + if err := writeNPMProxy(context.Background(), "http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + want := []string{ + "config set proxy http://proxy.example.com:8080", + "config set https-proxy http://proxy.example.com:8080", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("npm commands = %q, want %q", lines, want) + } +} + +func TestWriteAppConfigNPM(t *testing.T) { + logPath := installFakeCommand(t, "npm") + + if err := WriteAppConfig(AppNPM, "http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + if len(lines) != 2 { + t.Fatalf("expected two npm config calls, got %q", lines) + } +} + +func TestWriteNPMProxyCommandFailure(t *testing.T) { + _ = installFakeCommand(t, "npm") + t.Setenv("SYSPROXY_TEST_FAIL_ON", "https-proxy") + + err := writeNPMProxy(context.Background(), "http://proxy.example.com:8080") + if err == nil || !strings.Contains(err.Error(), "npm config set https-proxy") { + t.Fatalf("expected wrapped npm command error, got %v", err) + } +} + +func TestClearNPMProxy(t *testing.T) { + logPath := installFakeCommand(t, "npm") + + if err := clearNPMProxy(context.Background()); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + want := []string{ + "config delete proxy", + "config delete https-proxy", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("npm commands = %q, want %q", lines, want) + } +} + +func TestClearAppConfigNPM(t *testing.T) { + logPath := installFakeCommand(t, "npm") + + if err := ClearAppConfig(AppNPM); err != nil { + t.Fatal(err) + } + + lines := readCommandLog(t, logPath) + if len(lines) != 2 { + t.Fatalf("expected two npm delete calls, got %q", lines) + } +} + +func TestClearPipConf(t *testing.T) { + home := setTestHome(t) + path := filepath.Join(home, ".config", "pip", "pip.conf") + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + content := "[global]\nproxy = http://proxy.example.com:8080\nindex-url = https://pypi.org/simple\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := clearPipConf(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, "proxy = ") { + t.Fatalf("proxy entry should be removed, got %q", text) + } + if !strings.Contains(text, "index-url = https://pypi.org/simple") { + t.Fatalf("expected unrelated pip settings to stay, got %q", text) + } +} + +func TestClearAppConfigPip(t *testing.T) { + home := setTestHome(t) + path := filepath.Join(home, ".config", "pip", "pip.conf") + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("[global]\nproxy = http://proxy.example.com:8080\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := ClearAppConfig(AppPip); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "proxy = ") { + t.Fatalf("proxy entry should be removed, got %q", string(data)) + } +} + +func TestClearWgetRC(t *testing.T) { + home := setTestHome(t) + path := filepath.Join(home, ".wgetrc") + content := strings.Join([]string{ + "http_proxy = http://proxy.example.com:8080", + "https_proxy = http://proxy.example.com:8080", + "use_proxy = on", + "", + }, "\n") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := clearWgetRC(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, "http_proxy = ") || strings.Contains(text, "https_proxy = ") { + t.Fatalf("proxy entries should be removed, got %q", text) + } + if !strings.Contains(text, "use_proxy = on") { + t.Fatalf("expected unrelated wget settings to stay, got %q", text) + } +} + +func TestClearAppConfigWget(t *testing.T) { + home := setTestHome(t) + path := filepath.Join(home, ".wgetrc") + if err := os.WriteFile(path, []byte("http_proxy = http://proxy.example.com:8080\nhttps_proxy = http://proxy.example.com:8080\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := ClearAppConfig(AppWget); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "proxy = ") { + t.Fatalf("proxy entries should be removed, got %q", string(data)) + } +} + +func TestClearAppConfigUnsupported(t *testing.T) { + if err := ClearAppConfig("burp"); err == nil { + t.Fatal("expected unsupported app error") + } +} + +func TestWriteAppConfigContextCanceled(t *testing.T) { + logPath := installFakeCommand(t, "git") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := WriteAppConfigContext(ctx, AppGit, "http://proxy.example.com:8080") + if err == nil || err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + + if lines := readCommandLog(t, logPath); len(lines) != 0 { + t.Fatalf("expected no git commands after cancellation, got %q", lines) + } +} + +func TestClearAppConfigContextCanceled(t *testing.T) { + logPath := installFakeCommand(t, "npm") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := ClearAppConfigContext(ctx, AppNPM) + if err == nil || err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + + if lines := readCommandLog(t, logPath); len(lines) != 0 { + t.Fatalf("expected no npm commands after cancellation, got %q", lines) + } +} + +func TestEditINIFileReplacesOnlyTargetSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "pip.conf") + content := strings.Join([]string{ + "[global]", + "proxy = http://old.example.com:8080", + "index-url = https://pypi.org/simple", + "[install]", + "proxy = http://keep.example.com:8080", + "", + }, "\n") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := editINIFile(path, "global", "proxy", "http://new.example.com:8080"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "proxy = http://new.example.com:8080") { + t.Fatalf("expected updated proxy entry, got %q", text) + } + if strings.Contains(text, "proxy = http://old.example.com:8080") { + t.Fatalf("old proxy entry should be replaced, got %q", text) + } + if !strings.Contains(text, "[install]\nproxy = http://keep.example.com:8080") { + t.Fatalf("expected other section to stay unchanged, got %q", text) + } +} + +func TestEditINIFileAddsMissingSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "pip.conf") + content := "[install]\ntrusted-host = pypi.org\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := editINIFile(path, "global", "proxy", "http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "[global]\nproxy = http://proxy.example.com:8080\n") { + t.Fatalf("expected missing section to be appended, got %q", text) + } +} + +func TestRemoveINIKeyOnlyAffectsTargetSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "pip.conf") + content := strings.Join([]string{ + "[global]", + "proxy = http://proxy.example.com:8080", + "index-url = https://pypi.org/simple", + "[install]", + "proxy = http://keep.example.com:8080", + "", + }, "\n") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := removeINIKey(path, "global", "proxy"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, "[global]\nproxy = http://proxy.example.com:8080") { + t.Fatalf("global proxy entry should be removed, got %q", text) + } + if !strings.Contains(text, "index-url = https://pypi.org/simple") { + t.Fatalf("expected unrelated keys in target section to stay, got %q", text) + } + if !strings.Contains(text, "[install]\nproxy = http://keep.example.com:8080") { + t.Fatalf("expected same key in another section to stay, got %q", text) + } +} diff --git a/check_test.go b/check_test.go new file mode 100644 index 0000000..3c8cc45 --- /dev/null +++ b/check_test.go @@ -0,0 +1,91 @@ +package sysproxy + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "time" +) + +func TestCheckReachable(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { + _ = ln.Close() + }() + + accepted := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err == nil { + _ = conn.Close() + } + accepted <- err + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + if err := Check(ctx, "http://"+ln.Addr().String()); err != nil { + t.Fatalf("Check returned error for reachable proxy: %v", err) + } + + select { + case err := <-accepted: + if err != nil { + t.Fatalf("listener accept failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for test listener to accept connection") + } +} + +func TestCheckInvalidURL(t *testing.T) { + err := Check(context.Background(), "://bad url") + if err == nil { + t.Fatal("expected invalid URL error") + } + if !strings.Contains(err.Error(), "invalid proxy URL") { + t.Fatalf("expected validation error, got %v", err) + } +} + +func TestCheckDeadlineExceededUsesDefaultPort(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + err := Check(ctx, "http://127.0.0.1") + if err == nil { + t.Fatal("expected deadline exceeded error") + } + if !strings.Contains(err.Error(), "127.0.0.1:80") { + t.Fatalf("expected default port to be used in error, got %v", err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected wrapped context deadline exceeded, got %v", err) + } +} + +func TestDefaultProxyPort(t *testing.T) { + tests := []struct { + scheme string + want string + }{ + {scheme: "http", want: "80"}, + {scheme: "https", want: "443"}, + {scheme: "socks5", want: "1080"}, + {scheme: "socks4", want: "1080"}, + {scheme: "socks", want: "1080"}, + {scheme: "ftp", want: "80"}, + } + + for _, tt := range tests { + if got := defaultProxyPort(tt.scheme); got != tt.want { + t.Fatalf("defaultProxyPort(%q) = %q, want %q", tt.scheme, got, tt.want) + } + } +} diff --git a/context_api_test.go b/context_api_test.go new file mode 100644 index 0000000..1a3278e --- /dev/null +++ b/context_api_test.go @@ -0,0 +1,104 @@ +package sysproxy + +import ( + "context" + "os" + "testing" +) + +func TestSetContextCanceledDoesNotMutateEnv(t *testing.T) { + t.Cleanup(unsetEnvVars) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := SetContext(ctx, "http://proxy.example.com:8080", ScopeShell) + if err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + if got := os.Getenv("http_proxy"); got != "" { + t.Fatalf("http_proxy should stay unset, got %q", got) + } +} + +func TestUnsetContextCanceledDoesNotMutateEnv(t *testing.T) { + t.Cleanup(unsetEnvVars) + setEnvVars("http://proxy.example.com:8080") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := UnsetContext(ctx, ScopeShell) + if err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + if got := os.Getenv("http_proxy"); got == "" { + t.Fatal("http_proxy should remain set after canceled unset") + } +} + +func TestSetMultiContextCanceledDoesNotMutateEnv(t *testing.T) { + t.Cleanup(unsetEnvVars) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := SetMultiContext(ctx, ProxyConfig{HTTP: "http://proxy.example.com:8080"}, ScopeShell) + if err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + if got := os.Getenv("http_proxy"); got != "" { + t.Fatalf("http_proxy should stay unset, got %q", got) + } +} + +func TestSetPACContextCanceledDoesNotMutateEnv(t *testing.T) { + t.Cleanup(func() { + _ = os.Unsetenv("AUTOPROXY") + unsetEnvVars() + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := SetPACContext(ctx, "https://config.example.com/proxy.pac", ScopeShell) + if err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } + if got := os.Getenv("AUTOPROXY"); got != "" { + t.Fatalf("AUTOPROXY should stay unset, got %q", got) + } +} + +func TestGetContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := GetContext(ctx) + if err != context.Canceled { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestSetWrapperStillWorks(t *testing.T) { + t.Cleanup(unsetEnvVars) + + if err := Set("http://proxy.example.com:8080", ScopeShell); err != nil { + t.Fatal(err) + } + if got := os.Getenv("http_proxy"); got != "http://proxy.example.com:8080" { + t.Fatalf("http_proxy = %q", got) + } +} + +func TestUnsetWrapperStillWorks(t *testing.T) { + t.Cleanup(unsetEnvVars) + setEnvVars("http://proxy.example.com:8080") + + if err := Unset(ScopeShell); err != nil { + t.Fatal(err) + } + if got := os.Getenv("http_proxy"); got != "" { + t.Fatalf("http_proxy should be unset, got %q", got) + } +} diff --git a/context_helpers.go b/context_helpers.go new file mode 100644 index 0000000..f6d609e --- /dev/null +++ b/context_helpers.go @@ -0,0 +1,10 @@ +package sysproxy + +import "context" + +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..f2e4daf --- /dev/null +++ b/coverage.out @@ -0,0 +1,302 @@ +mode: set +github.com/mar0ls/go-sysproxy/appconfig.go:28.57,29.51 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:29.51,31.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:32.2,33.13 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:34.15,35.30 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:36.14,37.32 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:38.14,39.32 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:40.14,41.31 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:42.15,43.30 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:44.10,45.57 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:47.2,48.12 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:52.40,54.13 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:55.15,56.22 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:57.14,58.24 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:59.14,60.24 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:61.14,62.23 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:63.15,64.22 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:65.10,66.57 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:68.2,69.12 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:74.41,76.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:76.16,78.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:79.2,79.57 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:82.26,84.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:84.16,86.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:87.2,87.49 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:92.35,94.2 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:96.43,97.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:97.25,99.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:100.2,100.77 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:100.77,102.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:103.2,103.78 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:103.78,105.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:106.2,106.12 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:109.28,110.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:110.25,112.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:113.2,115.12 3 0 +github.com/mar0ls/go-sysproxy/appconfig.go:120.35,122.2 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:124.43,125.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:125.25,127.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:128.2,128.67 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:128.67,130.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:131.2,131.73 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:131.73,133.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:134.2,134.12 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:137.28,138.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:138.25,140.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:141.2,143.12 3 0 +github.com/mar0ls/go-sysproxy/appconfig.go:148.42,150.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:150.16,152.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:153.2,153.63 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:153.63,155.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:156.2,156.55 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:159.27,161.16 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:161.16,163.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:164.2,164.46 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:167.36,169.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:169.16,171.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:172.2,172.63 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:177.41,179.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:179.16,181.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:182.2,182.78 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:182.78,184.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:185.2,185.63 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:188.26,190.16 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:190.16,192.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:193.2,193.69 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:198.50,200.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:200.16,202.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:203.2,203.39 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:208.59,210.39 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:210.39,212.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:213.2,215.26 3 1 +github.com/mar0ls/go-sysproxy/appconfig.go:215.26,216.35 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:216.35,219.4 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:221.2,221.12 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:221.12,223.3 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:224.2,224.32 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:228.65,230.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:230.16,231.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:231.25,233.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:234.3,234.13 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:236.2,237.25 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:237.25,239.3 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:240.2,241.26 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:241.26,243.36 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:243.36,245.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:247.2,247.31 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:251.58,253.39 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:253.39,255.3 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:256.2,260.26 5 1 +github.com/mar0ls/go-sysproxy/appconfig.go:260.26,262.24 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:262.24,264.12 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:266.3,266.38 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:266.38,268.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:269.3,269.55 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:269.55,272.4 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:274.2,274.12 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:274.12,277.27 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:277.27,278.38 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:278.38,280.10 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:283.3,283.17 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:283.17,285.4 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:286.3,286.31 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:288.2,288.32 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:292.52,294.16 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:294.16,295.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:295.25,297.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:298.3,298.13 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:300.2,303.26 4 0 +github.com/mar0ls/go-sysproxy/appconfig.go:303.26,305.24 2 0 +github.com/mar0ls/go-sysproxy/appconfig.go:305.24,308.12 3 0 +github.com/mar0ls/go-sysproxy/appconfig.go:310.3,310.38 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:310.38,312.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:313.3,313.51 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:313.51,314.12 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:316.3,316.25 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:318.2,318.31 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:321.47,323.16 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:323.16,325.3 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:326.2,326.15 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:326.15,327.35 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:327.35,329.4 1 0 +github.com/mar0ls/go-sysproxy/appconfig.go:331.2,333.16 3 1 +github.com/mar0ls/go-sysproxy/appconfig.go:333.16,335.3 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:336.2,336.24 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:339.52,341.20 2 1 +github.com/mar0ls/go-sysproxy/appconfig.go:341.20,343.3 1 1 +github.com/mar0ls/go-sysproxy/appconfig.go:344.2,344.51 1 1 +github.com/mar0ls/go-sysproxy/check.go:17.56,18.51 1 0 +github.com/mar0ls/go-sysproxy/check.go:18.51,20.3 1 0 +github.com/mar0ls/go-sysproxy/check.go:21.2,22.16 2 0 +github.com/mar0ls/go-sysproxy/check.go:22.16,24.3 1 0 +github.com/mar0ls/go-sysproxy/check.go:25.2,26.16 2 0 +github.com/mar0ls/go-sysproxy/check.go:26.16,28.3 1 0 +github.com/mar0ls/go-sysproxy/check.go:29.2,32.16 4 0 +github.com/mar0ls/go-sysproxy/check.go:32.16,34.3 1 0 +github.com/mar0ls/go-sysproxy/check.go:35.2,35.21 1 0 +github.com/mar0ls/go-sysproxy/check.go:38.45,39.16 1 0 +github.com/mar0ls/go-sysproxy/check.go:40.15,41.15 1 0 +github.com/mar0ls/go-sysproxy/check.go:42.35,43.16 1 0 +github.com/mar0ls/go-sysproxy/check.go:44.10,45.14 1 0 +github.com/mar0ls/go-sysproxy/env.go:5.34,6.113 1 1 +github.com/mar0ls/go-sysproxy/env.go:6.113,8.3 1 1 +github.com/mar0ls/go-sysproxy/env.go:9.2,10.54 2 1 +github.com/mar0ls/go-sysproxy/env.go:13.21,17.4 1 1 +github.com/mar0ls/go-sysproxy/env.go:17.4,19.3 1 1 +github.com/mar0ls/go-sysproxy/env.go:22.35,23.113 1 0 +github.com/mar0ls/go-sysproxy/env.go:23.113,25.3 1 0 +github.com/mar0ls/go-sysproxy/env.go:26.2,28.36 3 0 +github.com/mar0ls/go-sysproxy/env.go:31.39,32.20 1 0 +github.com/mar0ls/go-sysproxy/env.go:32.20,35.3 2 0 +github.com/mar0ls/go-sysproxy/env.go:36.2,36.21 1 0 +github.com/mar0ls/go-sysproxy/env.go:36.21,39.3 2 0 +github.com/mar0ls/go-sysproxy/env.go:40.2,40.21 1 0 +github.com/mar0ls/go-sysproxy/env.go:40.21,43.3 2 0 +github.com/mar0ls/go-sysproxy/env.go:44.2,44.23 1 0 +github.com/mar0ls/go-sysproxy/env.go:44.23,47.3 2 0 +github.com/mar0ls/go-sysproxy/helpers.go:8.36,11.2 2 0 +github.com/mar0ls/go-sysproxy/helpers.go:13.40,15.16 2 0 +github.com/mar0ls/go-sysproxy/helpers.go:15.16,17.3 1 0 +github.com/mar0ls/go-sysproxy/helpers.go:18.2,18.21 1 0 +github.com/mar0ls/go-sysproxy/helpers.go:21.40,23.16 2 0 +github.com/mar0ls/go-sysproxy/helpers.go:23.16,25.3 1 0 +github.com/mar0ls/go-sysproxy/helpers.go:26.2,26.17 1 0 +github.com/mar0ls/go-sysproxy/logger.go:26.26,30.2 3 1 +github.com/mar0ls/go-sysproxy/logger.go:32.39,36.14 4 1 +github.com/mar0ls/go-sysproxy/logger.go:36.14,38.3 1 1 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:13.37,24.2 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:27.24,29.2 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:31.38,33.2 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:35.42,37.20 2 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:37.20,39.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:40.2,40.21 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:40.21,42.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:43.2,43.21 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:43.21,45.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:46.2,46.23 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:46.23,48.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:49.2,49.34 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:52.45,54.16 2 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:54.16,56.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:57.2,57.33 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:57.33,60.17 3 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:60.17,61.12 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:63.3,63.27 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:63.27,65.4 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:66.3,66.35 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:66.35,68.4 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:70.2,70.12 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:73.37,75.16 2 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:75.16,77.3 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:78.2,78.33 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:78.33,81.17 3 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:81.17,82.12 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:84.3,85.58 2 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:85.58,87.42 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:87.42,88.13 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:90.4,90.29 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:92.3,92.66 1 0 +github.com/mar0ls/go-sysproxy/rcfile_unix.go:94.2,94.12 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:46.51,47.51 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:47.51,49.3 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:50.2,51.16 2 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:51.16,53.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:54.2,54.15 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:55.18,58.13 3 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:59.17,63.13 4 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:64.19,68.13 4 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:69.10,70.57 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:77.36,78.15 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:79.18,82.13 3 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:83.17,87.13 4 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:88.19,92.13 4 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:93.10,94.57 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:99.28,101.2 1 1 +github.com/mar0ls/go-sysproxy/sysproxy.go:104.56,105.61 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:105.61,106.14 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:106.14,107.46 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:107.46,109.5 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:112.2,112.15 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:113.18,115.13 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:116.17,118.27 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:119.19,121.29 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:122.10,123.57 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:129.52,130.47 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:130.47,132.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:133.2,133.15 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:134.18,136.13 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:137.17,139.28 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:140.19,142.30 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:143.10,144.57 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:154.110,156.45 2 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:156.45,158.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:159.2,159.15 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:159.15,160.35 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:160.35,162.4 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:162.9,164.4 1 0 +github.com/mar0ls/go-sysproxy/sysproxy.go:166.2,166.16 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:11.44,13.2 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:15.32,17.16 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:17.16,19.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:20.2,20.31 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:20.31,21.19 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:21.19,25.4 3 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:25.9,29.4 3 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:30.3,32.64 3 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:34.2,34.12 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:37.26,39.16 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:39.16,41.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:42.2,42.31 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:42.31,46.3 3 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:47.2,47.12 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:50.34,52.38 2 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:52.38,54.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:55.2,56.16 2 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:56.16,58.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:59.2,61.56 3 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:61.56,62.10 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:63.48,64.18 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:65.43,66.65 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:67.41,68.63 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:71.2,71.42 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:71.42,73.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:74.2,74.50 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:77.40,79.16 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:79.16,81.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:82.2,82.31 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:82.31,85.3 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:86.2,86.12 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:89.44,91.16 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:91.16,93.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:94.2,94.31 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:94.31,95.21 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:95.21,98.4 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:99.3,99.22 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:99.22,102.4 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:103.3,103.22 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:103.22,106.4 2 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:107.3,107.24 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:107.24,109.4 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:111.2,111.12 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:114.47,116.16 2 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:116.16,118.3 1 0 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:119.2,120.56 2 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:120.56,121.59 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:121.59,122.12 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:124.3,124.47 1 1 +github.com/mar0ls/go-sysproxy/sysproxy_darwin.go:126.2,126.18 1 1 +github.com/mar0ls/go-sysproxy/validate.go:18.43,20.16 2 1 +github.com/mar0ls/go-sysproxy/validate.go:20.16,22.3 1 1 +github.com/mar0ls/go-sysproxy/validate.go:23.2,28.19 2 1 +github.com/mar0ls/go-sysproxy/validate.go:28.19,31.3 2 1 +github.com/mar0ls/go-sysproxy/validate.go:32.2,32.15 1 1 +github.com/mar0ls/go-sysproxy/validate.go:35.44,37.56 2 1 +github.com/mar0ls/go-sysproxy/validate.go:37.56,39.3 1 1 +github.com/mar0ls/go-sysproxy/validate.go:40.2,40.34 1 1 +github.com/mar0ls/go-sysproxy/validate.go:40.34,42.77 2 1 +github.com/mar0ls/go-sysproxy/validate.go:42.77,44.4 1 1 +github.com/mar0ls/go-sysproxy/validate.go:46.2,46.12 1 1 +github.com/mar0ls/go-sysproxy/validate.go:49.42,52.41 1 0 +github.com/mar0ls/go-sysproxy/validate.go:52.41,54.3 1 0 +github.com/mar0ls/go-sysproxy/validate.go:55.2,55.12 1 0 diff --git a/example_test.go b/example_test.go index 33b41f9..e74e140 100644 --- a/example_test.go +++ b/example_test.go @@ -23,6 +23,16 @@ func ExampleSet_noAuth() { fmt.Println("proxy set") } +func ExampleSetContext() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := sysproxy.SetContext(ctx, "http://proxy.example.com:8080", sysproxy.ScopeGlobal) + cancel() + if err != nil { + log.Fatal(err) + } + fmt.Println("proxy set") +} + func ExampleUnset() { if err := sysproxy.Unset(sysproxy.ScopeGlobal); err != nil { log.Fatal(err) @@ -74,6 +84,16 @@ func ExampleWriteAppConfig() { fmt.Println("git proxy configured") } +func ExampleWriteAppConfigContext() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := sysproxy.WriteAppConfigContext(ctx, sysproxy.AppGit, "http://proxy.example.com:8080") + cancel() + if err != nil { + log.Fatal(err) + } + fmt.Println("git proxy configured") +} + func ExampleSetLogger() { // Disable logging (restores the default no-op state). sysproxy.SetLogger(nil) diff --git a/sysproxy.go b/sysproxy.go index 4ff8433..8f2d626 100644 --- a/sysproxy.go +++ b/sysproxy.go @@ -8,6 +8,7 @@ // // err := sysproxy.Set("http://proxy.example.com:8080", sysproxy.ScopeGlobal) // err = sysproxy.Unset(sysproxy.ScopeGlobal) +// err = sysproxy.SetContext(ctx, "http://proxy.example.com:8080", sysproxy.ScopeGlobal) // // // temporary proxy — auto-restored on return // err = sysproxy.WithProxy(ctx, "socks5://proxy.example.com:1080", sysproxy.ScopeGlobal, func(ctx context.Context) error { @@ -44,9 +45,19 @@ type ProxyConfig struct { // // err := sysproxy.Set("http://user:pass@proxy.example.com:8080", sysproxy.ScopeGlobal) func Set(proxyURL string, scope ProxyScope) error { + return SetContext(context.Background(), proxyURL, scope) +} + +// SetContext configures the OS system proxy to proxyURL for the given scope, +// aborting before side effects if ctx is already canceled. +func SetContext(ctx context.Context, proxyURL string, scope ProxyScope) error { if err := validateProxyURL(proxyURL); err != nil { return err } + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } p, err := parse(proxyURL) if err != nil { return err @@ -63,7 +74,7 @@ func Set(proxyURL string, scope ProxyScope) error { return err case ScopeGlobal: setEnvVars(proxyURL) - err = setGlobal(p) + err = setGlobal(ctx, p) logf("set proxy scope=global url=%s err=%v", proxyURL, err) return err default: @@ -75,6 +86,16 @@ func Set(proxyURL string, scope ProxyScope) error { // // err := sysproxy.Unset(sysproxy.ScopeGlobal) func Unset(scope ProxyScope) error { + return UnsetContext(context.Background(), scope) +} + +// UnsetContext clears the OS system proxy for the given scope, aborting before +// side effects if ctx is already canceled. +func UnsetContext(ctx context.Context, scope ProxyScope) error { + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } switch scope { case ScopeShell: unsetEnvVars() @@ -87,7 +108,7 @@ func Unset(scope ProxyScope) error { return err case ScopeGlobal: unsetEnvVars() - err := unsetGlobal() + err := unsetGlobal(ctx) logf("unset proxy scope=global err=%v", err) return err default: @@ -97,11 +118,27 @@ func Unset(scope ProxyScope) error { // Get returns the current system proxy URL, or an error if none is configured. func Get() (string, error) { - return getGlobal() + return GetContext(context.Background()) +} + +// GetContext returns the current system proxy URL, or an error if none is +// configured. +func GetContext(ctx context.Context) (string, error) { + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return "", err + } + return getGlobal(ctx) } // SetMulti configures per-protocol proxies. Any field left empty is not changed. func SetMulti(cfg ProxyConfig, scope ProxyScope) error { + return SetMultiContext(context.Background(), cfg, scope) +} + +// SetMultiContext configures per-protocol proxies. Any field left empty is not +// changed. +func SetMultiContext(ctx context.Context, cfg ProxyConfig, scope ProxyScope) error { for _, u := range []string{cfg.HTTP, cfg.HTTPS, cfg.SOCKS} { if u != "" { if err := validateProxyURL(u); err != nil { @@ -109,6 +146,10 @@ func SetMulti(cfg ProxyConfig, scope ProxyScope) error { } } } + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } switch scope { case ScopeShell: setEnvVarsMulti(cfg) @@ -118,7 +159,7 @@ func SetMulti(cfg ProxyConfig, scope ProxyScope) error { return setUserMulti(cfg) case ScopeGlobal: setEnvVarsMulti(cfg) - return setGlobalMulti(cfg) + return setGlobalMulti(ctx, cfg) default: return fmt.Errorf("sysproxy: invalid scope %d", scope) } @@ -127,9 +168,19 @@ func SetMulti(cfg ProxyConfig, scope ProxyScope) error { // SetPAC configures the OS system proxy to use a Proxy Auto-Config (PAC) URL. // pacURL must start with http://, https://, or file://. func SetPAC(pacURL string, scope ProxyScope) error { + return SetPACContext(context.Background(), pacURL, scope) +} + +// SetPACContext configures the OS system proxy to use a Proxy Auto-Config +// (PAC) URL. pacURL must start with http://, https://, or file://. +func SetPACContext(ctx context.Context, pacURL string, scope ProxyScope) error { if err := validatePACURL(pacURL); err != nil { return err } + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return err + } switch scope { case ScopeShell: setEnvVarsPAC(pacURL) @@ -139,7 +190,7 @@ func SetPAC(pacURL string, scope ProxyScope) error { return setUserPAC(pacURL) case ScopeGlobal: setEnvVarsPAC(pacURL) - return setGlobalPAC(pacURL) + return setGlobalPAC(ctx, pacURL) default: return fmt.Errorf("sysproxy: invalid scope %d", scope) } @@ -152,8 +203,9 @@ func SetPAC(pacURL string, scope ProxyScope) 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 := Set(proxyURL, scope); err != nil { + if err := SetContext(ctx, proxyURL, scope); err != nil { return err } defer func() { diff --git a/sysproxy_darwin.go b/sysproxy_darwin.go index 1b195e7..7243611 100644 --- a/sysproxy_darwin.go +++ b/sysproxy_darwin.go @@ -3,56 +3,57 @@ package sysproxy import ( + "context" "fmt" "os/exec" "strings" ) -func runNetworkSetup(args ...string) error { - return exec.Command("networksetup", args...).Run() //nolint:noctx,gosec +func runNetworkSetup(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "networksetup", args...).Run() //nolint:gosec } -func setGlobal(p *proxy) error { - services, err := macOSNetworkServices() +func setGlobal(ctx context.Context, p *proxy) error { + services, err := macOSNetworkServices(ctx) if err != nil { return err } for _, svc := range services { if p.user != "" { - _ = runNetworkSetup("-setwebproxy", svc, p.host, p.port, "on", p.user, p.pass) - _ = runNetworkSetup("-setsecurewebproxy", svc, p.host, p.port, "on", p.user, p.pass) - _ = runNetworkSetup("-setsocksfirewallproxy", svc, p.host, p.port, "on", p.user, p.pass) + _ = runNetworkSetup(ctx, "-setwebproxy", svc, p.host, p.port, "on", p.user, p.pass) + _ = runNetworkSetup(ctx, "-setsecurewebproxy", svc, p.host, p.port, "on", p.user, p.pass) + _ = runNetworkSetup(ctx, "-setsocksfirewallproxy", svc, p.host, p.port, "on", p.user, p.pass) } else { - _ = runNetworkSetup("-setwebproxy", svc, p.host, p.port) - _ = runNetworkSetup("-setsecurewebproxy", svc, p.host, p.port) - _ = runNetworkSetup("-setsocksfirewallproxy", svc, p.host, p.port) + _ = runNetworkSetup(ctx, "-setwebproxy", svc, p.host, p.port) + _ = runNetworkSetup(ctx, "-setsecurewebproxy", svc, p.host, p.port) + _ = runNetworkSetup(ctx, "-setsocksfirewallproxy", svc, p.host, p.port) } - _ = runNetworkSetup("-setwebproxystate", svc, "on") - _ = runNetworkSetup("-setsecurewebproxystate", svc, "on") - _ = runNetworkSetup("-setsocksfirewallproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setwebproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setsecurewebproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setsocksfirewallproxystate", svc, "on") } return nil } -func unsetGlobal() error { - services, err := macOSNetworkServices() +func unsetGlobal(ctx context.Context) error { + services, err := macOSNetworkServices(ctx) if err != nil { return err } for _, svc := range services { - _ = runNetworkSetup("-setwebproxystate", svc, "off") - _ = runNetworkSetup("-setsecurewebproxystate", svc, "off") - _ = runNetworkSetup("-setsocksfirewallproxystate", svc, "off") + _ = runNetworkSetup(ctx, "-setwebproxystate", svc, "off") + _ = runNetworkSetup(ctx, "-setsecurewebproxystate", svc, "off") + _ = runNetworkSetup(ctx, "-setsocksfirewallproxystate", svc, "off") } return nil } -func getGlobal() (string, error) { - services, err := macOSNetworkServices() +func getGlobal(ctx context.Context) (string, error) { + services, err := macOSNetworkServices(ctx) if err != nil || len(services) == 0 { return "", fmt.Errorf("sysproxy: no network services found") } - out, err := exec.Command("networksetup", "-getwebproxy", services[0]).Output() //nolint:noctx,gosec + out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-getwebproxy", services[0]).Output() //nolint:gosec if err != nil { return "", err } @@ -74,45 +75,45 @@ func getGlobal() (string, error) { return "", fmt.Errorf("sysproxy: proxy not set") } -func setGlobalPAC(pacURL string) error { - services, err := macOSNetworkServices() +func setGlobalPAC(ctx context.Context, pacURL string) error { + services, err := macOSNetworkServices(ctx) if err != nil { return err } for _, svc := range services { - _ = runNetworkSetup("-setautoproxyurl", svc, pacURL) - _ = runNetworkSetup("-setautoproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setautoproxyurl", svc, pacURL) + _ = runNetworkSetup(ctx, "-setautoproxystate", svc, "on") } return nil } -func setGlobalMulti(cfg ProxyConfig) error { - services, err := macOSNetworkServices() +func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { + services, err := macOSNetworkServices(ctx) if err != nil { return err } for _, svc := range services { if cfg.HTTP != "" { - _ = runNetworkSetup("-setwebproxy", svc, hostFromURL(cfg.HTTP), portFromURL(cfg.HTTP)) - _ = runNetworkSetup("-setwebproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setwebproxy", svc, hostFromURL(cfg.HTTP), portFromURL(cfg.HTTP)) + _ = runNetworkSetup(ctx, "-setwebproxystate", svc, "on") } if cfg.HTTPS != "" { - _ = runNetworkSetup("-setsecurewebproxy", svc, hostFromURL(cfg.HTTPS), portFromURL(cfg.HTTPS)) - _ = runNetworkSetup("-setsecurewebproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setsecurewebproxy", svc, hostFromURL(cfg.HTTPS), portFromURL(cfg.HTTPS)) + _ = runNetworkSetup(ctx, "-setsecurewebproxystate", svc, "on") } if cfg.SOCKS != "" { - _ = runNetworkSetup("-setsocksfirewallproxy", svc, hostFromURL(cfg.SOCKS), portFromURL(cfg.SOCKS)) - _ = runNetworkSetup("-setsocksfirewallproxystate", svc, "on") + _ = runNetworkSetup(ctx, "-setsocksfirewallproxy", svc, hostFromURL(cfg.SOCKS), portFromURL(cfg.SOCKS)) + _ = runNetworkSetup(ctx, "-setsocksfirewallproxystate", svc, "on") } if cfg.NoProxy != "" { - _ = runNetworkSetup("-setproxybypassdomains", svc, cfg.NoProxy) + _ = runNetworkSetup(ctx, "-setproxybypassdomains", svc, cfg.NoProxy) } } return nil } -func macOSNetworkServices() ([]string, error) { - out, err := exec.Command("networksetup", "-listallnetworkservices").Output() //nolint:noctx +func macOSNetworkServices(ctx context.Context) ([]string, error) { + out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-listallnetworkservices").Output() if err != nil { return nil, fmt.Errorf("sysproxy: networksetup: %w", err) } diff --git a/sysproxy_linux.go b/sysproxy_linux.go index ccf4092..ea8e855 100644 --- a/sysproxy_linux.go +++ b/sysproxy_linux.go @@ -3,64 +3,65 @@ package sysproxy import ( + "context" "fmt" "os" "os/exec" "strings" ) -func runGsettings(args ...string) error { - return exec.Command("gsettings", args...).Run() //nolint:noctx,gosec +func runGsettings(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "gsettings", args...).Run() //nolint:gosec } -func runKwriteconfig5(args ...string) error { - return exec.Command("kwriteconfig5", args...).Run() //nolint:noctx,gosec +func runKwriteconfig5(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "kwriteconfig5", args...).Run() //nolint:gosec } -func setGlobal(p *proxy) error { +func setGlobal(ctx context.Context, p *proxy) error { switch detectDesktopEnv() { case "gnome": if isAvailable("gsettings") { - _ = runGsettings("set", "org.gnome.system.proxy", "mode", "manual") - _ = runGsettings("set", "org.gnome.system.proxy.http", "host", p.host) - _ = runGsettings("set", "org.gnome.system.proxy.http", "port", p.port) - _ = runGsettings("set", "org.gnome.system.proxy.https", "host", p.host) - _ = runGsettings("set", "org.gnome.system.proxy.https", "port", p.port) - _ = runGsettings("set", "org.gnome.system.proxy.socks", "host", p.host) - _ = runGsettings("set", "org.gnome.system.proxy.socks", "port", p.port) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", p.port) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", p.port) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", p.port) } case "kde": if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", p.rawURL) - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", p.rawURL) - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ftpProxy", p.rawURL) - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ftpProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", p.rawURL) } } return writeEtcEnvironment("/etc/environment", p.rawURL) } -func unsetGlobal() error { +func unsetGlobal(ctx context.Context) error { if isAvailable("gsettings") { - _ = runGsettings("set", "org.gnome.system.proxy", "mode", "none") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "none") } if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") } return clearEtcEnvironment("/etc/environment") } -func getGlobal() (string, error) { +func getGlobal(ctx context.Context) (string, error) { if !isAvailable("gsettings") { return "", fmt.Errorf("sysproxy: gsettings not available") } - out, err := exec.Command("gsettings", "get", "org.gnome.system.proxy", "mode").Output() //nolint:noctx + 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") } - host, err1 := exec.Command("gsettings", "get", "org.gnome.system.proxy.http", "host").Output() //nolint:noctx - port, err2 := exec.Command("gsettings", "get", "org.gnome.system.proxy.http", "port").Output() //nolint:noctx + 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() if err1 != nil || err2 != nil { return "", fmt.Errorf("sysproxy: cannot read proxy settings") } @@ -72,54 +73,54 @@ func getGlobal() (string, error) { return "http://" + h + ":" + p, nil } -func setGlobalPAC(pacURL string) error { +func setGlobalPAC(ctx context.Context, pacURL string) error { switch detectDesktopEnv() { case "gnome": if isAvailable("gsettings") { - _ = runGsettings("set", "org.gnome.system.proxy", "mode", "auto") - _ = runGsettings("set", "org.gnome.system.proxy", "autoconfig-url", pacURL) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "auto") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "autoconfig-url", pacURL) } case "kde": if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "2") - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Proxy Config Script", pacURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "2") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Proxy Config Script", pacURL) } } return nil } -func setGlobalMulti(cfg ProxyConfig) error { +func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { switch detectDesktopEnv() { case "gnome": if isAvailable("gsettings") { if cfg.HTTP != "" { - _ = runGsettings("set", "org.gnome.system.proxy", "mode", "manual") - _ = runGsettings("set", "org.gnome.system.proxy.http", "host", hostFromURL(cfg.HTTP)) - _ = runGsettings("set", "org.gnome.system.proxy.http", "port", portFromURL(cfg.HTTP)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", hostFromURL(cfg.HTTP)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", portFromURL(cfg.HTTP)) } if cfg.HTTPS != "" { - _ = runGsettings("set", "org.gnome.system.proxy.https", "host", hostFromURL(cfg.HTTPS)) - _ = runGsettings("set", "org.gnome.system.proxy.https", "port", portFromURL(cfg.HTTPS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", hostFromURL(cfg.HTTPS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", portFromURL(cfg.HTTPS)) } if cfg.SOCKS != "" { - _ = runGsettings("set", "org.gnome.system.proxy.socks", "host", hostFromURL(cfg.SOCKS)) - _ = runGsettings("set", "org.gnome.system.proxy.socks", "port", portFromURL(cfg.SOCKS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", hostFromURL(cfg.SOCKS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", portFromURL(cfg.SOCKS)) } } case "kde": if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") if cfg.HTTP != "" { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", cfg.HTTP) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", cfg.HTTP) } if cfg.HTTPS != "" { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", cfg.HTTPS) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", cfg.HTTPS) } if cfg.SOCKS != "" { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", cfg.SOCKS) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", cfg.SOCKS) } if cfg.NoProxy != "" { - _ = runKwriteconfig5("--file", "kioslaverc", "--group", "Proxy Settings", "--key", "NoProxyFor", cfg.NoProxy) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "NoProxyFor", cfg.NoProxy) } } } diff --git a/sysproxy_other.go b/sysproxy_other.go index 8af4995..ac14e72 100644 --- a/sysproxy_other.go +++ b/sysproxy_other.go @@ -3,19 +3,22 @@ package sysproxy import ( + "context" "fmt" "runtime" ) -func setGlobal(_ *proxy) error { return errUnsupported() } -func unsetGlobal() error { return errUnsupported() } -func getGlobal() (string, error) { return "", errUnsupported() } -func setGlobalPAC(_ string) error { return errUnsupported() } -func setGlobalMulti(_ ProxyConfig) error { return errUnsupported() } -func setUser(_ string) error { return errUnsupported() } -func unsetUser() error { return errUnsupported() } -func setUserPAC(_ string) error { return errUnsupported() } -func setUserMulti(_ ProxyConfig) error { return errUnsupported() } +func setGlobal(_ context.Context, _ *proxy) error { return errUnsupported() } +func unsetGlobal(_ context.Context) error { return errUnsupported() } +func getGlobal(_ context.Context) (string, error) { return "", errUnsupported() } +func setGlobalPAC(_ context.Context, _ string) error { return errUnsupported() } +func setGlobalMulti(_ context.Context, _ ProxyConfig) error { + return errUnsupported() +} +func setUser(_ string) error { return errUnsupported() } +func unsetUser() error { return errUnsupported() } +func setUserPAC(_ string) error { return errUnsupported() } +func setUserMulti(_ ProxyConfig) error { return errUnsupported() } func errUnsupported() error { return fmt.Errorf("sysproxy: unsupported OS %q", runtime.GOOS) diff --git a/sysproxy_test.go b/sysproxy_test.go index 1caaed1..95ebef4 100644 --- a/sysproxy_test.go +++ b/sysproxy_test.go @@ -2,6 +2,7 @@ package sysproxy import ( "os" + "path/filepath" "strings" "testing" ) @@ -193,15 +194,29 @@ func TestSetLogger(t *testing.T) { // ── WriteAppConfig / ClearAppConfig ────────────────────────────────────────── -func TestWriteAppConfigCurl(t *testing.T) { +func setTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + if volume := filepath.VolumeName(home); volume != "" { + t.Setenv("HOMEDRIVE", volume) + t.Setenv("HOMEPATH", strings.TrimPrefix(home, volume)) + } + + return home +} + +func TestWriteAppConfigCurl(t *testing.T) { + home := setTestHome(t) if err := WriteAppConfig(AppCurl, "http://proxy.example.com:8080"); err != nil { t.Fatal(err) } - data, err := os.ReadFile(home + "/.curlrc") //nolint:gosec + data, err := os.ReadFile(filepath.Join(home, ".curlrc")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -211,29 +226,27 @@ func TestWriteAppConfigCurl(t *testing.T) { } func TestClearAppConfigCurl(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) + home := setTestHome(t) _ = WriteAppConfig(AppCurl, "http://proxy.example.com:8080") if err := ClearAppConfig(AppCurl); err != nil { t.Fatal(err) } - data, _ := os.ReadFile(home + "/.curlrc") //nolint:gosec + data, _ := os.ReadFile(filepath.Join(home, ".curlrc")) //nolint:gosec if strings.Contains(string(data), "proxy") { t.Errorf("proxy should be removed, got: %s", data) } } func TestWriteAppConfigPip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) + home := setTestHome(t) if err := WriteAppConfig(AppPip, "http://proxy.example.com:8080"); err != nil { t.Fatal(err) } - data, err := os.ReadFile(home + "/.config/pip/pip.conf") //nolint:gosec + data, err := os.ReadFile(filepath.Join(home, ".config", "pip", "pip.conf")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -243,14 +256,13 @@ func TestWriteAppConfigPip(t *testing.T) { } func TestWriteAppConfigWget(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) + home := setTestHome(t) if err := WriteAppConfig(AppWget, "http://proxy.example.com:8080"); err != nil { t.Fatal(err) } - data, _ := os.ReadFile(home + "/.wgetrc") //nolint:gosec + data, _ := os.ReadFile(filepath.Join(home, ".wgetrc")) //nolint:gosec if !strings.Contains(string(data), "http_proxy = http://proxy.example.com:8080") { t.Errorf("unexpected .wgetrc content: %s", data) } diff --git a/sysproxy_windows.go b/sysproxy_windows.go index eeeccd3..56bbe47 100644 --- a/sysproxy_windows.go +++ b/sysproxy_windows.go @@ -3,6 +3,7 @@ package sysproxy import ( + "context" "fmt" "net/url" "os" @@ -12,43 +13,43 @@ import ( const regKey = `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` -func runReg(args ...string) error { - return exec.Command("reg", args...).Run() //nolint:noctx +func runReg(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "reg", args...).Run() } -func runCmdkey(args ...string) error { - return exec.Command("cmdkey", args...).Run() //nolint:noctx +func runCmdkey(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "cmdkey", args...).Run() } -func runRundll32(args ...string) error { - return exec.Command("rundll32.exe", args...).Run() //nolint:noctx +func runRundll32(ctx context.Context, args ...string) error { + return exec.CommandContext(normalizeContext(ctx), "rundll32.exe", args...).Run() } -func setGlobal(p *proxy) error { - _ = runReg("add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") - _ = runReg("add", regKey, "/v", "ProxyServer", "/t", "REG_SZ", "/d", p.host+":"+p.port, "/f") - _ = runReg("add", regKey, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", "localhost;127.0.0.1;::1", "/f") +func setGlobal(ctx context.Context, p *proxy) error { + _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") + _ = runReg(ctx, "add", regKey, "/v", "ProxyServer", "/t", "REG_SZ", "/d", p.host+":"+p.port, "/f") + _ = runReg(ctx, "add", regKey, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", "localhost;127.0.0.1;::1", "/f") if p.user != "" { - _ = runCmdkey("/add:"+p.host, "/user:"+p.user, "/pass:"+p.pass) + _ = runCmdkey(ctx, "/add:"+p.host, "/user:"+p.user, "/pass:"+p.pass) } - _ = runRundll32("wininet.dll,InternetSetOptionEx") + _ = runRundll32(ctx, "wininet.dll,InternetSetOptionEx") return nil } -func unsetGlobal() error { - _ = runReg("add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f") - _ = runReg("delete", regKey, "/v", "ProxyServer", "/f") - _ = runReg("delete", regKey, "/v", "ProxyOverride", "/f") - if host, err := currentProxyHost(); err == nil && host != "" { - _ = runCmdkey("/delete:" + host) +func unsetGlobal(ctx context.Context) error { + _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f") + _ = runReg(ctx, "delete", regKey, "/v", "ProxyServer", "/f") + _ = runReg(ctx, "delete", regKey, "/v", "ProxyOverride", "/f") + if host, err := currentProxyHost(ctx); err == nil && host != "" { + _ = runCmdkey(ctx, "/delete:"+host) } return nil } // currentProxyHost reads the proxy host from the registry so Unset can clean // up Credential Manager without requiring the caller to pass the original URL. -func currentProxyHost() (string, error) { - out, err := exec.Command("reg", "query", regKey, "/v", "ProxyServer").Output() //nolint:noctx +func currentProxyHost(ctx context.Context) (string, error) { + out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyServer").Output() if err != nil { return "", err } @@ -67,12 +68,12 @@ func currentProxyHost() (string, error) { return "", fmt.Errorf("sysproxy: ProxyServer not found in registry") } -func getGlobal() (string, error) { - out, err := exec.Command("reg", "query", regKey, "/v", "ProxyEnable").Output() //nolint:noctx +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") } - out, err = exec.Command("reg", "query", regKey, "/v", "ProxyServer").Output() //nolint:noctx + out, err = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyServer").Output() if err != nil { return "", fmt.Errorf("sysproxy: cannot read ProxyServer") } @@ -87,14 +88,14 @@ func getGlobal() (string, error) { return "", fmt.Errorf("sysproxy: proxy not set") } -func setGlobalPAC(pacURL string) error { - _ = runReg("add", regKey, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", pacURL, "/f") - _ = runReg("add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f") +func setGlobalPAC(ctx context.Context, pacURL string) error { + _ = runReg(ctx, "add", regKey, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", pacURL, "/f") + _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f") return nil } -func setGlobalMulti(cfg ProxyConfig) error { - _ = runReg("add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") +func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { + _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") var servers []string if cfg.HTTP != "" { servers = append(servers, "http="+hostPortFromURL(cfg.HTTP)) @@ -106,10 +107,10 @@ func setGlobalMulti(cfg ProxyConfig) error { servers = append(servers, "socks="+hostPortFromURL(cfg.SOCKS)) } if len(servers) > 0 { - _ = runReg("add", regKey, "/v", "ProxyServer", "/t", "REG_SZ", "/d", strings.Join(servers, ";"), "/f") + _ = runReg(ctx, "add", regKey, "/v", "ProxyServer", "/t", "REG_SZ", "/d", strings.Join(servers, ";"), "/f") } if cfg.NoProxy != "" { - _ = runReg("add", regKey, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", cfg.NoProxy, "/f") + _ = runReg(ctx, "add", regKey, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", cfg.NoProxy, "/f") } return nil } From ee6e7aa7cbd64f5cca005364ab35a25b8b094a63 Mon Sep 17 00:00:00 2001 From: Marcin <120790937+mar0ls@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:50:20 +0200 Subject: [PATCH 3/3] add context-aware API variants and improve cross-platform test coverage --- .gitignore | 1 + appconfig_test.go | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6b5c166..59f3c67 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ ROADMAP.md +coverage.out \ No newline at end of file diff --git a/appconfig_test.go b/appconfig_test.go index 6678939..2c6f2aa 100644 --- a/appconfig_test.go +++ b/appconfig_test.go @@ -65,7 +65,11 @@ func readCommandLog(t *testing.T, path string) []string { return nil } - return strings.Split(text, "\n") + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimSuffix(line, "\r") + } + return lines } func TestWriteGitProxy(t *testing.T) {