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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,53 @@ jobs:
- name: Test
run: go test -v -race ./...

- name: Test TinyGo transport policy
run: go test -tags=tinygo -run TestTinyGo ./...

- name: Build WebAssembly
run: GOOS=js GOARCH=wasm go build ./...

- name: Build WASI
run: GOOS=wasip1 GOARCH=wasm go build ./...

tinygo:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false

- name: Install TinyGo and Wasmtime
working-directory: ${{ runner.temp }}
run: |
curl --fail --location --silent --show-error --output tinygo.tar.gz https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo0.42.0.linux-amd64.tar.gz
echo 'b87688fa2e19cee7d813cad7fd7dadb71dff3198e47125aba66ba4af5e490438 tinygo.tar.gz' | sha256sum --check
tar -xzf tinygo.tar.gz
echo "$RUNNER_TEMP/tinygo/bin" >> "$GITHUB_PATH"
curl --fail --location --silent --show-error --output wasmtime.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v44.0.1/wasmtime-v44.0.1-x86_64-linux.tar.xz
echo 'afd58715f105e3a7f454169daed22168c5736ec5f225fb04c4ac62c54c9508a3 wasmtime.tar.xz' | sha256sum --check
tar -xJf wasmtime.tar.xz
echo "$RUNNER_TEMP/wasmtime-v44.0.1-x86_64-linux" >> "$GITHUB_PATH"

- name: Test TinyGo WebAssembly transport policy and sync
run: tinygo test -target=wasm -run '^TestTinyGo' -v .

- name: Test TinyGo WASI transport policy and sync
run: tinygo test -target=wasip1 -run '^TestTinyGo' -v .

lint:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ If an advertised chunk disappears, the client refreshes the properties once. `Ne

The lower-level `NewReader` and `NewRawReader` functions parse saved chunks or caller-managed streams without HTTP.

These readers are available under TinyGo. TinyGo HTTP transports cannot enforce the connection-time public-address policy, so `Client.Sync` returns `ErrUnprotectedTransport` unless `AllowPrivateAddresses` is set. For synchronization, supply a host-compatible `HTTPClient` and enforce address and redirect restrictions in the host.

## Command

Build the command locally:
Expand All @@ -96,7 +98,7 @@ The command writes newline-delimited JSON. A sync begins with its mode, followed

Private, loopback, CGNAT, and NAT64 addresses are refused by default. The public-address policy bypasses environment proxies and rejects an explicit proxy, custom `RoundTripper`, or custom dialer because those paths cannot be checked at connection time. Set `AllowPrivateAddresses` when using one of those transports and enforce its address policy separately. The command exposes the same opt-out as `--allow-private`.

The default transport applies 30-second connection and response-header timeouts. Body reads also have a 30-second idle timeout, configurable with `ClientOptions.ResponseIdleTimeout`. Parser limits, retry counts, and locally generated backoff bounds are available through the same options type. A valid server `Retry-After` delay is honored without shortening it.
Under standard Go, the default transport applies 30-second connection and response-header timeouts. Body reads also have a 30-second idle timeout, configurable with `ClientOptions.ResponseIdleTimeout`. Parser limits, retry counts, and locally generated backoff bounds are available through the same options type. A valid server `Retry-After` delay is honored without shortening it.

## Use with other git-pkgs packages

Expand Down
2 changes: 2 additions & 0 deletions benchmark_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !tinygo

package nexus

import (
Expand Down
84 changes: 1 addition & 83 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ var (
// ErrUnsafeURL is returned when a URL violates the remote URL policy.
ErrUnsafeURL = errors.New("nexus: remote URL rejected")
// ErrUnprotectedTransport is returned when strict address policy cannot be
// enforced by a custom HTTP transport or proxy.
// enforced by the HTTP transport or proxy.
ErrUnprotectedTransport = errors.New("nexus: HTTP transport cannot enforce public-address policy")
)

Expand Down Expand Up @@ -207,51 +207,6 @@ func defaultUserAgent() string {
return "git-pkgs-nexus/" + version
}

func protectedTransport(base http.RoundTripper, policy addressPolicy) (http.RoundTripper, error) {
usingDefault := base == nil
defaultTransport, defaultIsHTTP := http.DefaultTransport.(*http.Transport)
if transport, ok := base.(*http.Transport); ok && defaultIsHTTP && transport == defaultTransport {
usingDefault = true
}
if base == nil {
base = http.DefaultTransport
}
if transport, ok := base.(*http.Transport); ok {
clone := transport.Clone()
if !policy.allowPrivate {
if !usingDefault && clone.Proxy != nil {
return nil, fmt.Errorf("%w: proxies require AllowPrivateAddresses", ErrUnprotectedTransport)
}
if !usingDefault && clone.DialContext != nil {
return nil, fmt.Errorf("%w: custom dialers require AllowPrivateAddresses", ErrUnprotectedTransport)
}
hasCustomTLSDialer := clone.DialTLSContext != nil
hasCustomTLSDialer = hasCustomTLSDialer || clone.DialTLS != nil //nolint:staticcheck // DialTLS remains supported and bypasses DialContext.
if hasCustomTLSDialer {
return nil, fmt.Errorf("%w: custom TLS dialers require AllowPrivateAddresses", ErrUnprotectedTransport)
}
clone.Proxy = nil
}
if clone.ResponseHeaderTimeout == 0 {
clone.ResponseHeaderTimeout = defaultHeaderTimeout
}
underlying := clone.DialContext
if !policy.allowPrivate || underlying == nil {
dialer := &net.Dialer{Timeout: defaultDialTimeout, KeepAlive: defaultKeepAlive}
underlying = dialer.DialContext
}
clone.DialContext = policy.dialContext(underlying)
base = clone
} else if !policy.allowPrivate {
return nil, fmt.Errorf("%w: custom RoundTripper requires AllowPrivateAddresses", ErrUnprotectedTransport)
}
return &checkingRoundTripper{
base: base,
policy: policy,
responseIdleTimeout: policy.responseIdleTimeout,
}, nil
}

type checkingRoundTripper struct {
base http.RoundTripper
policy addressPolicy
Expand Down Expand Up @@ -305,43 +260,6 @@ func (policy addressPolicy) checkHost(ctx context.Context, host string) error {
return nil
}

func (policy addressPolicy) dialContext(underlying func(context.Context, string, string) (net.Conn, error)) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if policy.allowPrivate {
return underlying(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if parsed := net.ParseIP(host); parsed != nil {
if err := checkPublicIP(parsed); err != nil {
return nil, err
}
return underlying(ctx, network, address)
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
var lastErr error
for _, candidate := range addresses {
if err := checkPublicIP(candidate.IP); err != nil {
return nil, fmt.Errorf("nexus: host %s: %w", host, err)
}
connection, dialErr := underlying(ctx, network, net.JoinHostPort(candidate.IP.String(), port))
if dialErr == nil {
return connection, nil
}
lastErr = dialErr
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("nexus: no addresses resolved for %s", host)
}
}

const (
idleBodyOpen uint32 = iota
idleBodyClosed
Expand Down
41 changes: 0 additions & 41 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -52,46 +51,6 @@ func TestClientRejectsInvalidOptionsBeforeRequest(t *testing.T) {
}
}

func TestClientRejectsUnprotectedTransports(t *testing.T) {
proxyURL := &url.URL{Scheme: "http", Host: "proxy.example.test"}
tests := []struct {
name string
transport http.RoundTripper
}{
{
name: "custom RoundTripper",
transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport should not be called")
}),
},
{
name: "proxy",
transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
},
{
name: "custom dialer",
transport: &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("dialer should not be called")
}},
},
{
name: "custom TLS dialer",
transport: &http.Transport{DialTLSContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("dialer should not be called")
}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := NewClient(ClientOptions{HTTPClient: &http.Client{Transport: test.transport}})
_, err := client.Sync(context.Background(), "https://8.8.8.8/repository", nil)
if !errors.Is(err, ErrUnprotectedTransport) {
t.Fatalf("Sync error = %v, want ErrUnprotectedTransport", err)
}
})
}
}

func TestClientAllowsCustomTransportWithPrivateAddresses(t *testing.T) {
var calls atomic.Int32
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
Expand Down
92 changes: 92 additions & 0 deletions transport_std.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//go:build !tinygo

package nexus

import (
"context"
"fmt"
"net"
"net/http"
)

func protectedTransport(base http.RoundTripper, policy addressPolicy) (http.RoundTripper, error) {
usingDefault := base == nil
defaultTransport, defaultIsHTTP := http.DefaultTransport.(*http.Transport)
if transport, ok := base.(*http.Transport); ok && defaultIsHTTP && transport == defaultTransport {
usingDefault = true
}
if base == nil {
base = http.DefaultTransport
}
if transport, ok := base.(*http.Transport); ok {
clone := transport.Clone()
if !policy.allowPrivate {
if !usingDefault && clone.Proxy != nil {
return nil, fmt.Errorf("%w: proxies require AllowPrivateAddresses", ErrUnprotectedTransport)
}
if !usingDefault && clone.DialContext != nil {
return nil, fmt.Errorf("%w: custom dialers require AllowPrivateAddresses", ErrUnprotectedTransport)
}
hasCustomTLSDialer := clone.DialTLSContext != nil
hasCustomTLSDialer = hasCustomTLSDialer || clone.DialTLS != nil //nolint:staticcheck // DialTLS remains supported and bypasses DialContext.
if hasCustomTLSDialer {
return nil, fmt.Errorf("%w: custom TLS dialers require AllowPrivateAddresses", ErrUnprotectedTransport)
}
clone.Proxy = nil
}
if clone.ResponseHeaderTimeout == 0 {
clone.ResponseHeaderTimeout = defaultHeaderTimeout
}
underlying := clone.DialContext
if !policy.allowPrivate || underlying == nil {
dialer := &net.Dialer{Timeout: defaultDialTimeout, KeepAlive: defaultKeepAlive}
underlying = dialer.DialContext
}
clone.DialContext = policy.dialContext(underlying)
base = clone
} else if !policy.allowPrivate {
return nil, fmt.Errorf("%w: custom RoundTripper requires AllowPrivateAddresses", ErrUnprotectedTransport)
}
return &checkingRoundTripper{
base: base,
policy: policy,
responseIdleTimeout: policy.responseIdleTimeout,
}, nil
}

func (policy addressPolicy) dialContext(underlying func(context.Context, string, string) (net.Conn, error)) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if policy.allowPrivate {
return underlying(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if parsed := net.ParseIP(host); parsed != nil {
if err := checkPublicIP(parsed); err != nil {
return nil, err
}
return underlying(ctx, network, address)
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
var lastErr error
for _, candidate := range addresses {
if err := checkPublicIP(candidate.IP); err != nil {
return nil, fmt.Errorf("nexus: host %s: %w", host, err)
}
connection, dialErr := underlying(ctx, network, net.JoinHostPort(candidate.IP.String(), port))
if dialErr == nil {
return connection, nil
}
lastErr = dialErr
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("nexus: no addresses resolved for %s", host)
}
}
52 changes: 52 additions & 0 deletions transport_std_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build !tinygo

package nexus

import (
"context"
"errors"
"net"
"net/http"
"net/url"
"testing"
)

func TestClientRejectsUnprotectedTransports(t *testing.T) {
proxyURL := &url.URL{Scheme: "http", Host: "proxy.example.test"}
tests := []struct {
name string
transport http.RoundTripper
}{
{
name: "custom RoundTripper",
transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport should not be called")
}),
},
{
name: "proxy",
transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
},
{
name: "custom dialer",
transport: &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("dialer should not be called")
}},
},
{
name: "custom TLS dialer",
transport: &http.Transport{DialTLSContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("dialer should not be called")
}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := NewClient(ClientOptions{HTTPClient: &http.Client{Transport: test.transport}})
_, err := client.Sync(context.Background(), "https://8.8.8.8/repository", nil)
if !errors.Is(err, ErrUnprotectedTransport) {
t.Fatalf("Sync error = %v, want ErrUnprotectedTransport", err)
}
})
}
}
Loading
Loading