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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v0.5.2 (2026-05-27)

### Fixed

- **Security:** HTTP download timeout (120s) — was: no timeout, hangs forever on network issues
- **Security:** Decompression bomb protection via `io.LimitReader` (200MB cap, gosec G110)
- **Security:** HTTP status code validation — was: no check for 404/500 responses
- **Correctness:** `dst.Close()` error checked on write path — was: unchecked, corrupted library on Windows
- **Correctness:** macOS prints `DYLD_LIBRARY_PATH` — was: `LD_LIBRARY_PATH` (wrong for macOS)
- **Correctness:** `wgpu.Init()` searches `./lib/` directory — auto-setup default location now auto-discovered

### Added

- Download progress indicator with Content-Length (MB)
- 10 new tests: `Download()` (happy/404/network error), `FindLibrary()` (env/missing/lib-dir)
- Package documentation for `internal/nativelib`
- README: `WGPU_NATIVE_PATH` instructions for all platforms after auto-setup

## v0.5.1 (2026-05-27)

### Added
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ go run github.com/go-webgpu/webgpu/cmd/setup@latest

This downloads the correct wgpu-native v29 binary for your platform (Windows/macOS/Linux, amd64/arm64) into `./lib/`.

The library is found automatically from `./lib/` — no environment variable needed. To override the search path explicitly:

```bash
# Linux
export WGPU_NATIVE_PATH=./lib/libwgpu_native.so

# macOS
export WGPU_NATIVE_PATH=./lib/libwgpu_native.dylib

# Windows (PowerShell)
$env:WGPU_NATIVE_PATH = "lib\wgpu_native.dll"

# Windows (cmd)
set WGPU_NATIVE_PATH=lib\wgpu_native.dll
```

Or copy the library to your project root — it will also be found automatically.

### wgpu-native Setup (manual)

Download from [gfx-rs/wgpu-native releases](https://github.com/gfx-rs/wgpu-native/releases/tag/v29.0.0.0) and place in your project directory or system PATH.
Expand Down
57 changes: 46 additions & 11 deletions internal/nativelib/download.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package nativelib provides platform detection, download, and extraction
// of the wgpu-native binary library.
package nativelib

import (
Expand All @@ -7,39 +9,68 @@ import (
"net/http"
"os"
"path/filepath"
"time"
)

// httpTimeout is the timeout for downloading the wgpu-native library.
// 120 seconds is generous for a ~15-20 MB file even on slow connections.
const httpTimeout = 120 * time.Second

// maxLibSize is the maximum decompressed size of a single zip entry.
// Protects against decompression bombs; wgpu-native is ~15-20 MB.
const maxLibSize = 200 * 1024 * 1024 // 200 MB

// TODO: add SHA256 checksum verification when wgpu-native starts publishing checksums.
// Currently relies on HTTPS transport security only.

// Download downloads the file at url to a temporary file and returns its path.
// The caller is responsible for removing the temporary file when done.
func Download(url string) (string, error) {
resp, err := http.Get(url) //nolint:gosec // G107: URL constructed from constants
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Get(url) //nolint:gosec // G107: URL constructed from constants
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
defer resp.Body.Close() //nolint:errcheck // read-only close, error not actionable

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, url)
return "", fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode)
}

if resp.ContentLength > 0 {
fmt.Printf("Downloading %s (%.1f MB)...\n", url, float64(resp.ContentLength)/1024/1024)
} else {
fmt.Printf("Downloading %s...\n", url)
}

tmpFile, err := os.CreateTemp("", "wgpu-native-*.zip")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer tmpFile.Close()
tmpPath := tmpFile.Name()

if _, err := io.Copy(tmpFile, resp.Body); err != nil {
os.Remove(tmpFile.Name())
if _, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxLibSize)); err != nil {
tmpFile.Close() //nolint:errcheck // cleanup on write error
os.Remove(tmpPath)
return "", fmt.Errorf("download write: %w", err)
}

return tmpFile.Name(), nil
if err := tmpFile.Close(); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("close %s: %w", tmpPath, err)
}

return tmpPath, nil
}

// ExtractLibrary extracts the file named libName from the zip archive at
// zipPath, writing it to destDir. Returns the path of the extracted file.
func ExtractLibrary(zipPath, destDir, libName string) (string, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return "", fmt.Errorf("open zip: %w", err)
}
defer r.Close()
defer r.Close() //nolint:errcheck // read-only close

for _, f := range r.File {
name := filepath.Base(f.Name)
Expand All @@ -51,19 +82,23 @@ func ExtractLibrary(zipPath, destDir, libName string) (string, error) {
if err != nil {
return "", fmt.Errorf("open %s in zip: %w", f.Name, err)
}
defer src.Close()
defer src.Close() //nolint:errcheck // read-only close

destPath := filepath.Join(destDir, libName)
dst, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("create %s: %w", destPath, err)
}
defer dst.Close()

if _, err := io.Copy(dst, src); err != nil {
if _, err := io.Copy(dst, io.LimitReader(src, maxLibSize)); err != nil {
dst.Close() //nolint:errcheck // cleanup on write error
return "", fmt.Errorf("extract %s: %w", libName, err)
}

if err := dst.Close(); err != nil {
return "", fmt.Errorf("close %s: %w", destPath, err)
}

return destPath, nil
}

Expand Down
68 changes: 61 additions & 7 deletions internal/nativelib/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,65 @@ package nativelib

import (
"archive/zip"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)

func TestDownload(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("fake content")) //nolint:errcheck // test response
}))
defer ts.Close()

path, err := Download(ts.URL)
if err != nil {
t.Fatal(err)
}
defer os.Remove(path)

data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "fake content" {
t.Errorf("content = %q, want %q", string(data), "fake content")
}
}

func TestDownloadHTTPError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()

_, err := Download(ts.URL)
if err == nil {
t.Fatal("expected error for HTTP 404")
}
}

func TestDownloadNetworkError(t *testing.T) {
// Use a server that immediately closes the connection.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
t.Error("ResponseWriter does not support Hijacker")
return
}
conn, _, _ := hj.Hijack()
conn.Close()
}))
defer ts.Close()

_, err := Download(ts.URL)
if err == nil {
t.Fatal("expected error for connection drop")
}
}

func TestExtractLibrary(t *testing.T) {
zipPath := createTestZip(t, "test.dll", []byte("fake dll content"))
destDir := t.TempDir()
Expand Down Expand Up @@ -59,7 +113,7 @@ func TestExtractLibraryNotFound(t *testing.T) {

func TestExtractLibraryInvalidZip(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "bad.zip")
os.WriteFile(tmpFile, []byte("not a zip"), 0o644)
os.WriteFile(tmpFile, []byte("not a zip"), 0o644) //nolint:errcheck // test setup

_, err := ExtractLibrary(tmpFile, t.TempDir(), "test.dll")
if err == nil {
Expand All @@ -74,15 +128,15 @@ func createTestZip(t *testing.T, name string, content []byte) string {
if err != nil {
t.Fatal(err)
}
defer f.Close()
defer f.Close() //nolint:errcheck // test helper

w := zip.NewWriter(f)
entry, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
entry.Write(content)
w.Close()
entry.Write(content) //nolint:errcheck // test helper
w.Close() //nolint:errcheck // test helper
return path
}

Expand All @@ -93,14 +147,14 @@ func createTestZipNested(t *testing.T, name string, content []byte) string {
if err != nil {
t.Fatal(err)
}
defer f.Close()
defer f.Close() //nolint:errcheck // test helper

w := zip.NewWriter(f)
entry, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
entry.Write(content)
w.Close()
entry.Write(content) //nolint:errcheck // test helper
w.Close() //nolint:errcheck // test helper
return path
}
68 changes: 68 additions & 0 deletions internal/nativelib/platform_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package nativelib

import (
"os"
"path/filepath"
"runtime"
"testing"
)
Expand Down Expand Up @@ -88,3 +90,69 @@ func TestDetectPlatformUnsupportedArch(t *testing.T) {
t.Errorf("archName() = %q, want x86_64 or aarch64", p.archName())
}
}

func TestFindLibraryEnvPath(t *testing.T) {
// Create a real file to point WGPU_NATIVE_PATH at.
tmpFile := filepath.Join(t.TempDir(), "test_lib")
if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}

t.Setenv("WGPU_NATIVE_PATH", tmpFile)

found := FindLibrary()
if found != tmpFile {
t.Errorf("FindLibrary() = %q, want %q", found, tmpFile)
}
}

func TestFindLibraryEnvPathMissing(t *testing.T) {
// When WGPU_NATIVE_PATH points to a non-existent file, FindLibrary should
// fall through to the other search paths rather than returning the invalid path.
t.Setenv("WGPU_NATIVE_PATH", "/nonexistent/path/wgpu_native.so")

// We cannot assert a specific result (it depends on what is installed),
// but we can assert it does not return the broken env path.
found := FindLibrary()
if found == "/nonexistent/path/wgpu_native.so" {
t.Error("FindLibrary() returned non-existent WGPU_NATIVE_PATH value")
}
}

func TestFindLibraryLibDir(t *testing.T) {
// Create a temp dir and populate it with a lib subdirectory containing the library.
tmpDir := t.TempDir()
libDir := filepath.Join(tmpDir, "lib")
if err := os.MkdirAll(libDir, 0o755); err != nil {
t.Fatal(err)
}

libName := LibraryName()
libFile := filepath.Join(libDir, libName)
if err := os.WriteFile(libFile, []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}

// Unset env override so we exercise the file-system search.
t.Setenv("WGPU_NATIVE_PATH", "")

// FindLibrary resolves relative paths, so we must run from tmpDir.
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer os.Chdir(origDir) //nolint:errcheck // test cleanup

found := FindLibrary()
if found == "" {
t.Fatal("FindLibrary() returned empty — expected to find lib in ./lib/")
}

absLib, _ := filepath.Abs(libFile)
if found != absLib {
t.Errorf("FindLibrary() = %q, want %q", found, absLib)
}
}
16 changes: 12 additions & 4 deletions setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ func Install(destDir string) (string, error) {
return "", err
}

absPath, _ := filepath.Abs(libPath)
absPath, err := filepath.Abs(libPath)
if err != nil {
absPath = libPath
}
fmt.Printf("Installed: %s\n\n", absPath)
printUsage(platform, absPath, destDir)

Expand All @@ -67,7 +70,10 @@ func FindLibrary() string {
}

func printUsage(platform *nativelib.Platform, absPath, destDir string) {
dir, _ := filepath.Abs(destDir)
dir, err := filepath.Abs(destDir)
if err != nil {
dir = destDir
}

fmt.Println("To use, set environment variable:")
switch platform.OS {
Expand All @@ -77,11 +83,13 @@ func printUsage(platform *nativelib.Platform, absPath, destDir string) {
fmt.Printf(" export WGPU_NATIVE_PATH=%s\n", absPath)
}

fmt.Println("\nOr add directory to PATH:")
fmt.Println("\nOr add directory to library path:")
switch platform.OS {
case "windows":
fmt.Printf(" set PATH=%s;%%PATH%%\n", dir)
default:
case "darwin":
fmt.Printf(" export DYLD_LIBRARY_PATH=%s:$DYLD_LIBRARY_PATH\n", dir)
default: // linux and others
fmt.Printf(" export LD_LIBRARY_PATH=%s:$LD_LIBRARY_PATH\n", dir)
}
}
Loading
Loading