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|TestPortable' ./fetch ./client ./safehttp

- 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 supplied clients and transport policy
run: tinygo test -target=wasm -run 'TestTinyGo|TestPortable' -v ./fetch ./client ./safehttp

- name: Test TinyGo WASI supplied clients and transport policy
run: tinygo test -target=wasip1 -run 'TestTinyGo|TestPortable' -v ./fetch ./client ./safehttp

lint:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ statusCode, err := c.Head(ctx, "https://registry.npmjs.org/lodash")

The `fetch` sub-package provides streaming artifact downloads with retry, circuit breaking, DNS caching, and URL resolution.

Under TinyGo, pass a host-compatible `*http.Client` through `fetch.WithHTTPClient`; the default fetch transport returns `errors.ErrUnsupported`. Registry API clients also accept a supplied client through `registries.WithHTTPClient`. The host must enforce address and redirect restrictions. `safehttp.New` and `WithSafeHTTP` return clients whose requests fail with `errors.ErrUnsupported`, since TinyGo transports bypass their dial-time address checks. The standalone IP-checking functions remain available. Native Go retains DNS caching and transport protection.

### Fetching artifacts

```go
Expand Down
46 changes: 46 additions & 0 deletions client/transport_portable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package client_test

import (
"context"
"io"
"net/http"
"strings"
"testing"

"github.com/git-pkgs/registries"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestPortableRegistryWithHTTPClient(t *testing.T) {
calls := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
if request.URL.String() != "https://registry.example.test/demo" || request.Header.Get("Accept") != "application/json" {
t.Errorf("unexpected request: %s, headers: %v", request.URL, request.Header)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"_id":"demo", "name":"demo", "description":"Fixture package",
"dist-tags":{"latest":"1.0.0"},
"versions":{"1.0.0":{"name":"demo","version":"1.0.0","license":"MIT"}}
}`)),
}, nil
})
client := registries.NewClient(registries.WithHTTPClient(&http.Client{Transport: transport}))
registry, err := registries.New("npm", "https://registry.example.test", client)
if err != nil {
t.Fatal(err)
}
pkg, err := registry.FetchPackage(context.Background(), "demo")
if err != nil {
t.Fatal(err)
}
if pkg.Name != "demo" || pkg.Description != "Fixture package" || calls != 1 {
t.Errorf("package = %+v, requests = %d", pkg, calls)
}
}
32 changes: 32 additions & 0 deletions client/transport_tinygo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//go:build tinygo

package client_test

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

"github.com/git-pkgs/registries"
)

func TestTinyGoWithSafeHTTP(t *testing.T) {
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Error("WithSafeHTTP called the unprotected transport")
return nil, errors.New("unexpected request")
})
client := registries.NewClient(
registries.WithHTTPClient(&http.Client{Transport: transport}),
registries.WithSafeHTTP(),
registries.WithMaxRetries(0),
)
registry, err := registries.New("npm", "https://registry.example.test", client)
if err != nil {
t.Fatal(err)
}
_, err = registry.FetchPackage(context.Background(), "demo")
if !errors.Is(err, errors.ErrUnsupported) {
t.Errorf("FetchPackage error = %v, want ErrUnsupported", err)
}
}
96 changes: 10 additions & 86 deletions fetch/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,21 @@ import (
"io"
"math"
"math/rand"
"net"
"net/http"
"strconv"
"time"

"github.com/rs/dnscache"

"github.com/git-pkgs/registries/safehttp"
)

const (
dnsRefreshInterval = 5 * time.Minute
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
httpClientTimeout = 5 * time.Minute
responseHeaderTimeout = 60 * time.Second
maxIdleConns = 100
maxIdleConnsPerHost = 10
idleConnTimeout = 90 * time.Second
tlsHandshakeTimeout = 10 * time.Second
defaultMaxRetries = 3
defaultBaseDelay = 500 * time.Millisecond
backoffBase = 2
jitterFactor = 0.1
serverErrThreshold = 500
maxErrBodySize = 1024
httpClientTimeout = 5 * time.Minute
defaultMaxRetries = 3
defaultBaseDelay = 500 * time.Millisecond
backoffBase = 2
jitterFactor = 0.1
serverErrThreshold = 500
maxErrBodySize = 1024
)

var (
Expand Down Expand Up @@ -122,79 +111,14 @@ func WithAllowPrivateHosts(hosts ...string) Option {

// NewFetcher creates a new Fetcher with the given options.
// Callers should invoke Close when done to release the DNS refresh goroutine.
// Under TinyGo, requests require WithHTTPClient.
func NewFetcher(opts ...Option) *Fetcher {
resolver := &dnscache.Resolver{}
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(dnsRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
resolver.Refresh(true)
case <-stop:
return
}
}
}()

dialer := &net.Dialer{
Timeout: dialTimeout,
KeepAlive: dialKeepAlive,
}

var f *Fetcher
f = &Fetcher{
client: &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := resolver.LookupHost(ctx, host)
if err != nil {
return nil, err
}
// Gate every resolved IP against the safehttp block
// list (loopback, RFC1918, CGNAT, link-local, ...)
// before dialing. The dial is to the resolved IP
// directly so a rebind between gate and connect
// cannot escape.
var lastErr error
for _, ip := range ips {
if parsed := net.ParseIP(ip); parsed != nil {
if err := f.ipChecker.Check(host, parsed); err != nil {
lastErr = err
continue
}
}
conn, derr := dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
if derr == nil {
return conn, nil
}
lastErr = derr
}
if lastErr == nil {
return nil, fmt.Errorf("no IPs resolved for %s", host)
}
return nil, fmt.Errorf("dialing %s: %w", host, lastErr)
},
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ResponseHeaderTimeout: responseHeaderTimeout,
ExpectContinueTimeout: 1 * time.Second,
},
},
f := &Fetcher{
userAgent: "git-pkgs-proxy/1.0",
maxRetries: defaultMaxRetries,
baseDelay: defaultBaseDelay,
stop: stop,
}
f.initHTTPClient()
for _, opt := range opts {
opt(f)
}
Expand Down
88 changes: 88 additions & 0 deletions fetch/transport_portable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package fetch_test

import (
"context"
"io"
"net/http"
"strconv"
"strings"
"testing"

"github.com/git-pkgs/registries/fetch"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestPortableFetcherWithHTTPClient(t *testing.T) {
const artifactURL = "https://repo.example.test/org/example/demo/1.0/demo-1.0.pom"
const content = `<project><modelVersion>4.0.0</modelVersion><groupId>org.example</groupId><artifactId>demo</artifactId><version>1.0</version></project>`
var methods []string
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
methods = append(methods, request.Method)
if request.URL.String() != artifactURL || request.Header.Get("Authorization") != "Bearer fixture-token" {
t.Errorf("unexpected request: %s, headers: %v", request.URL, request.Header)
}
body := content
if request.Method == http.MethodHead {
body = ""
}
return &http.Response{
StatusCode: http.StatusOK,
Request: request,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{
"Content-Length": {strconv.Itoa(len(content))},
"Content-Type": {"application/xml"},
"Etag": {`"fixture"`},
},
}, nil
})
f := fetch.NewFetcher(
fetch.WithHTTPClient(&http.Client{Transport: transport}),
fetch.WithAuthFunc(func(string) (string, string) { return "Authorization", "Bearer fixture-token" }),
)
t.Cleanup(func() {
if err := f.Close(); err != nil {
t.Error(err)
}
})

artifact, err := f.Fetch(context.Background(), artifactURL)
if err != nil {
t.Fatal(err)
}
checkArtifactBody(t, artifact, content)

observed, err := f.FetchObserved(context.Background(), artifactURL)
if err != nil {
t.Fatal(err)
}
checkArtifactBody(t, observed.Artifact, content)
if !observed.Observation.Complete || observed.Observation.ByteCount != int64(len(content)) {
t.Errorf("observation = %+v", observed.Observation)
}

size, contentType, err := f.Head(context.Background(), artifactURL)
if err != nil || size != int64(len(content)) || contentType != "application/xml" {
t.Errorf("Head = %d, %q, %v", size, contentType, err)
}
if got := strings.Join(methods, ","); got != "GET,GET,HEAD" {
t.Errorf("methods = %q", got)
}
}

func checkArtifactBody(t *testing.T, artifact *fetch.Artifact, want string) {
t.Helper()
body, err := io.ReadAll(artifact.Body)
if closeErr := artifact.Body.Close(); closeErr != nil {
t.Error(closeErr)
}
if err != nil || string(body) != want {
t.Errorf("body = %q, %v", body, err)
}
if artifact.Size != int64(len(want)) || artifact.ContentType != "application/xml" || artifact.ETag != `"fixture"` {
t.Errorf("artifact = %+v", artifact)
}
}
Loading
Loading