From feb121e14579368611e288fadcb6aea5f3e62d62 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 Oct 2026 19:32:38 +0200 Subject: [PATCH 1/5] wit: fix loader paths and runtime cleanup Normalize guest paths so Windows separators do not break WIT loading. Close the embedded runtime after each load to release directory handles. --- wit/load.go | 5 +-- wit/load_test.go | 70 +++++++++++++++++++++++++++++++++++++++++ wit/load_tinygo_test.go | 25 +++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 wit/load_test.go create mode 100644 wit/load_tinygo_test.go diff --git a/wit/load.go b/wit/load.go index 342bc994..67da87c2 100644 --- a/wit/load.go +++ b/wit/load.go @@ -61,9 +61,9 @@ func loadWIT(path string, reader io.Reader) (*Resolve, error) { if path != "" { path = strings.TrimPrefix(path, "./") - args = append(args, path) + args = append(args, filepath.ToSlash(path)) dir := filepath.Dir(path) - fsMap[dir] = os.DirFS(dir) + fsMap[filepath.ToSlash(dir)] = os.DirFS(dir) } else { stdin = reader } @@ -71,6 +71,7 @@ func loadWIT(path string, reader io.Reader) (*Resolve, error) { if err != nil { return nil, err } + defer wasmTools.Close(ctx) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} err = wasmTools.Run(ctx, stdin, stdout, stderr, fsMap, args...) diff --git a/wit/load_test.go b/wit/load_test.go new file mode 100644 index 00000000..e92e92fe --- /dev/null +++ b/wit/load_test.go @@ -0,0 +1,70 @@ +//go:build !tinygo + +package wit + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadWITPaths(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "nested", "example.wit") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("package example:paths; world example {}"), 0o644); err != nil { + t.Fatal(err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + rel, err := filepath.Rel(cwd, file) + if err != nil { + t.Fatal(err) + } + for _, input := range []struct { + name string + path string + }{ + {"absolute", file}, + {"absolute slash", filepath.ToSlash(file)}, + {"relative", rel}, + {"relative slash", filepath.ToSlash(rel)}, + } { + t.Run(input.name, func(t *testing.T) { + resolved, err := LoadWIT(input.path) + if err != nil { + t.Fatal(err) + } + if len(resolved.Worlds) != 1 || resolved.Worlds[0].Name != "example" { + t.Fatalf("resolved worlds: %v", resolved.Worlds) + } + }) + } +} + +func TestLoadWITDirectoryDependencies(t *testing.T) { + root := t.TempDir() + dependency := filepath.Join(root, "deps", "dep") + if err := os.MkdirAll(dependency, 0o755); err != nil { + t.Fatal(err) + } + for name, text := range map[string]string{ + filepath.Join(root, "main.wit"): "package example:paths; world example { import example:dep/api; }", + filepath.Join(dependency, "api.wit"): "package example:dep; interface api { ping: func(); }", + } { + if err := os.WriteFile(name, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + resolved, err := LoadWIT(root) + if err != nil { + t.Fatal(err) + } + if len(resolved.Packages) != 2 || len(resolved.Worlds) != 1 || resolved.Worlds[0].Name != "example" { + t.Fatalf("resolved packages/worlds: %v / %v", resolved.Packages, resolved.Worlds) + } +} diff --git a/wit/load_tinygo_test.go b/wit/load_tinygo_test.go new file mode 100644 index 00000000..1cd78c25 --- /dev/null +++ b/wit/load_tinygo_test.go @@ -0,0 +1,25 @@ +//go:build tinygo + +package wit + +import ( + "strings" + "testing" +) + +func TestLoadWITUnsupportedBackend(t *testing.T) { + for _, input := range []struct { + name string + load func() (*Resolve, error) + }{ + {"file", func() (*Resolve, error) { return LoadWIT("example.wit") }}, + {"reader", func() (*Resolve, error) { return DecodeWIT(strings.NewReader("package example:paths;")) }}, + } { + t.Run(input.name, func(t *testing.T) { + resolved, err := input.load() + if resolved != nil || err == nil || !strings.Contains(err.Error(), "wasm-tools disabled under TinyGo") { + t.Fatalf("expected disabled-backend error, got %v, %v", resolved, err) + } + }) + } +} From c0fdd9e5431b0076cee9b3231d65eb97adc54948 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 24 Oct 2026 21:29:06 +0200 Subject: [PATCH 2/5] fix(gen): stop module lookup at Windows roots Compare each directory with its parent to detect filesystem roots. The slash-only check looped forever at Windows drive roots when no go.mod existed. Keep nested package suffixes while ascending. --- internal/go/gen/module.go | 13 ++++--------- internal/go/gen/module_test.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/go/gen/module.go b/internal/go/gen/module.go index 1553a6a4..4b781bc6 100644 --- a/internal/go/gen/module.go +++ b/internal/go/gen/module.go @@ -35,17 +35,12 @@ func PackagePath(dir string) (string, error) { file = filepath.Join(dir, "go.mod") info, err := os.Stat(file) if err != nil { - if dir == "/" { + parent := filepath.Dir(dir) + if parent == dir { return "", errors.New("unable to locate a go.mod file") } - // Pop up to parent dir - var rest string - dir, rest = filepath.Split(dir) - if dir == "" { - return "", errors.New("unable to locate a go.mod file") - } - dir = filepath.Clean(dir) - subdirs = path.Join(rest, subdirs) + subdirs = path.Join(filepath.Base(dir), subdirs) + dir = parent continue } if info.IsDir() { diff --git a/internal/go/gen/module_test.go b/internal/go/gen/module_test.go index 227fb496..125ebfa6 100644 --- a/internal/go/gen/module_test.go +++ b/internal/go/gen/module_test.go @@ -2,6 +2,7 @@ package gen import ( "os" + "path/filepath" "testing" "go.bytecodealliance.org/internal/relpath" @@ -28,3 +29,21 @@ func TestPackagePath(t *testing.T) { t.Errorf("PackagePath(%q): expected error, got nil", tmp) } } + +func TestPackagePathNestedModule(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/plugin\n\ngo 1.26.0\n"), 0o600); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "internal", "bindings") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatal(err) + } + got, err := PackagePath(nested) + if err != nil { + t.Fatal(err) + } + if want := "example.com/plugin/internal/bindings"; got != want { + t.Errorf("PackagePath(%q) = %q, want %q", nested, got, want) + } +} From 8ff57fada0f57c2c9cc164ebef8576fd5f1314f7 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 24 Oct 2026 21:36:16 +0200 Subject: [PATCH 3/5] test: fix Windows paths and permission checks Build fixture paths with filepath.Rel and filepath.Join. Test Windows directory creation permissions with an ACL and compare created permissions with a control directory on the same filesystem. Remove empty test directories before TinyGo cleanup. --- internal/witcli/dir_test.go | 67 +++++++++++++++++++++++++++++++++--- wit/bindgen/testdata_test.go | 9 +++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/internal/witcli/dir_test.go b/internal/witcli/dir_test.go index c3ad69c2..1a81a3c0 100644 --- a/internal/witcli/dir_test.go +++ b/internal/witcli/dir_test.go @@ -4,6 +4,7 @@ import ( "errors" "io/fs" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -14,6 +15,7 @@ func TestFindOrCreateDirFind(t *testing.T) { temp := t.TempDir() dir := filepath.Join(temp, "existing") + cleanupEmptyDirs(t, dir, temp) if err := os.Mkdir(dir, os.ModeDir|os.ModePerm); err != nil { t.Fatal(err) } @@ -48,8 +50,16 @@ func TestFindOrCreateDirFind(t *testing.T) { func TestFindOrCreateDirCreate(t *testing.T) { temp := t.TempDir() - + control := filepath.Join(temp, "control") dir := filepath.Join(temp, "new") + cleanupEmptyDirs(t, dir, control, temp) + if err := os.Mkdir(control, 0o755); err != nil { + t.Fatal(err) + } + controlInfo, err := os.Stat(control) + if err != nil { + t.Fatal(err) + } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Errorf("Expected %s to not exist, but it did.", dir) @@ -59,6 +69,16 @@ func TestFindOrCreateDirCreate(t *testing.T) { if err != nil { t.Fatal(err) } + if info == nil || !info.IsDir() { + t.Fatalf("FindOrCreateDir returned non-directory information: %v", info) + } + createdInfo, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if !createdInfo.IsDir() { + t.Fatal("FindOrCreateDir did not create a directory") + } // Skip remaining tests on incompatible platforms if runtime.Compiler == "tinygo" || strings.Contains(runtime.GOARCH, "wasm") { @@ -66,12 +86,25 @@ func TestFindOrCreateDirCreate(t *testing.T) { } gotPerm := info.Mode().Perm() - wantPerm := os.FileMode(0o755) + // Compare filesystem-applied permissions, including umask and Windows mode mapping. + wantPerm := controlInfo.Mode().Perm() if gotPerm != wantPerm { - t.Errorf("FindOrCreateDirExisting returned directory with file permissions %v; want %v", gotPerm, wantPerm) + t.Errorf("FindOrCreateDir returned directory with file permissions %v; want %v", gotPerm, wantPerm) } } +// TinyGo's Windows RemoveAll cannot enumerate directories; remove owned leaves first. +func cleanupEmptyDirs(t *testing.T, paths ...string) { + t.Helper() + t.Cleanup(func() { + for _, path := range paths { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Errorf("remove test directory %s: %v", path, err) + } + } + }) +} + func TestFindOrCreateDirPermissionDenied(t *testing.T) { // Skip test on incompatible platforms if runtime.Compiler == "tinygo" || strings.Contains(runtime.GOARCH, "wasm") { @@ -81,14 +114,38 @@ func TestFindOrCreateDirPermissionDenied(t *testing.T) { temp := t.TempDir() protected := filepath.Join(temp, "protected") - if err := os.Mkdir(protected, os.FileMode(0o400)); err != nil { + if err := os.Mkdir(protected, 0o755); err != nil { t.Fatal(err) } + if runtime.GOOS == "windows" { + // Windows directory creation uses ACLs; chmod does not remove this permission. + icacls := filepath.Join(os.Getenv("SystemRoot"), "System32", "icacls.exe") + if output, err := exec.Command(icacls, protected, "/deny", "*S-1-1-0:(AD)").CombinedOutput(); err != nil { + t.Fatalf("deny subdirectory creation: %v\n%s", err, output) + } + t.Cleanup(func() { + if output, err := exec.Command(icacls, protected, "/remove:d", "*S-1-1-0").CombinedOutput(); err != nil { + t.Errorf("restore directory permissions: %v\n%s", err, output) + } + }) + } else { + if err := os.Chmod(protected, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(protected, 0o755); err != nil { + t.Errorf("restore directory permissions: %v", err) + } + }) + } dir := filepath.Join(protected, "permission-denied") - _, err := FindOrCreateDir(dir) + info, err := FindOrCreateDir(dir) if !errors.Is(err, fs.ErrPermission) { t.Errorf("Expected creation within protected directory to fail, but it did not: %v", err) } + if info != nil { + t.Errorf("failed creation returned file information: %v", info) + } } diff --git a/wit/bindgen/testdata_test.go b/wit/bindgen/testdata_test.go index 5073c486..3ca11773 100644 --- a/wit/bindgen/testdata_test.go +++ b/wit/bindgen/testdata_test.go @@ -9,7 +9,6 @@ import ( "io/fs" "os" "os/exec" - "path" "path/filepath" "strings" "sync" @@ -86,7 +85,7 @@ func validateGeneratedGo(t *testing.T, res *wit.Resolve, origin string) { return } - dir := path.Join(generatedPath, origin) + dir := filepath.Join(generatedPath, origin) err := os.MkdirAll(dir, fs.ModePerm) if err != nil { t.Error(err) @@ -218,8 +217,12 @@ func TestGenerateTestdata(t *testing.T) { return } err := loadTestdata(func(path string, res *wit.Resolve) error { + relative, err := filepath.Rel(testdataPath, path) + if err != nil { + return err + } t.Run(path, func(t *testing.T) { - origin := strings.TrimSuffix(strings.TrimPrefix(path, testdataPath), ".wit.json") + origin := strings.TrimSuffix(relative, ".wit.json") validateGeneratedGo(t, res, origin) }) return nil From ef8688c794ac1634b925e029110a0080cf8cd945 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 24 Oct 2026 21:47:25 +0200 Subject: [PATCH 4/5] test: clean generator fixtures on Windows TinyGo --- internal/go/gen/module_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/go/gen/module_test.go b/internal/go/gen/module_test.go index 125ebfa6..18e5b6cc 100644 --- a/internal/go/gen/module_test.go +++ b/internal/go/gen/module_test.go @@ -32,10 +32,20 @@ func TestPackagePath(t *testing.T) { func TestPackagePathNestedModule(t *testing.T) { root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/plugin\n\ngo 1.26.0\n"), 0o600); err != nil { + module := filepath.Join(root, "go.mod") + internal := filepath.Join(root, "internal") + nested := filepath.Join(internal, "bindings") + // TinyGo's Windows RemoveAll cannot enumerate directories. + t.Cleanup(func() { + for _, path := range []string{nested, internal, module, root} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Errorf("remove test fixture %s: %v", path, err) + } + } + }) + if err := os.WriteFile(module, []byte("module example.com/plugin\n\ngo 1.26.0\n"), 0o600); err != nil { t.Fatal(err) } - nested := filepath.Join(root, "internal", "bindings") if err := os.MkdirAll(nested, 0o700); err != nil { t.Fatal(err) } From 96b233ae342376f9ac9a5f8f29c2d5e051f646dc Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 24 Oct 2026 22:13:23 +0200 Subject: [PATCH 5/5] test: support Windows and WASI test environments Keep WIT path fixtures on the checkout volume and WIT goldens on LF endings. Remove owned temporary files explicitly for TinyGo. Run the existing Go version matrix on Windows as well as Linux. --- .gitattributes | 1 + .github/workflows/test.yaml | 3 ++- internal/go/gen/module_test.go | 7 +++++-- internal/witcli/dir_test.go | 12 +++++++++--- wit/load_test.go | 15 ++++++++++++++- 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a5c7c744 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.wit text eol=lf diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f603626b..b899a233 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,10 +40,11 @@ jobs: # Test with Go test-go: name: Test with Go - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: matrix: + os: [ubuntu-latest, windows-latest] go-version: ["1.23", "1.24"] steps: - name: Checkout repo diff --git a/internal/go/gen/module_test.go b/internal/go/gen/module_test.go index 18e5b6cc..b577f009 100644 --- a/internal/go/gen/module_test.go +++ b/internal/go/gen/module_test.go @@ -31,11 +31,14 @@ func TestPackagePath(t *testing.T) { } func TestPackagePathNestedModule(t *testing.T) { - root := t.TempDir() + root, err := os.MkdirTemp("", t.Name()+"-") + if err != nil { + t.Fatal(err) + } module := filepath.Join(root, "go.mod") internal := filepath.Join(root, "internal") nested := filepath.Join(internal, "bindings") - // TinyGo's Windows RemoveAll cannot enumerate directories. + // TinyGo lacks recursive removal on Windows and WASI. t.Cleanup(func() { for _, path := range []string{nested, internal, module, root} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { diff --git a/internal/witcli/dir_test.go b/internal/witcli/dir_test.go index 1a81a3c0..44188bdd 100644 --- a/internal/witcli/dir_test.go +++ b/internal/witcli/dir_test.go @@ -12,7 +12,10 @@ import ( ) func TestFindOrCreateDirFind(t *testing.T) { - temp := t.TempDir() + temp, err := os.MkdirTemp("", t.Name()+"-") + if err != nil { + t.Fatal(err) + } dir := filepath.Join(temp, "existing") cleanupEmptyDirs(t, dir, temp) @@ -49,7 +52,10 @@ func TestFindOrCreateDirFind(t *testing.T) { } func TestFindOrCreateDirCreate(t *testing.T) { - temp := t.TempDir() + temp, err := os.MkdirTemp("", t.Name()+"-") + if err != nil { + t.Fatal(err) + } control := filepath.Join(temp, "control") dir := filepath.Join(temp, "new") cleanupEmptyDirs(t, dir, control, temp) @@ -93,7 +99,7 @@ func TestFindOrCreateDirCreate(t *testing.T) { } } -// TinyGo's Windows RemoveAll cannot enumerate directories; remove owned leaves first. +// TinyGo lacks recursive removal on Windows and WASI; remove owned leaves first. func cleanupEmptyDirs(t *testing.T, paths ...string) { t.Helper() t.Cleanup(func() { diff --git a/wit/load_test.go b/wit/load_test.go index e92e92fe..08a73086 100644 --- a/wit/load_test.go +++ b/wit/load_test.go @@ -9,7 +9,20 @@ import ( ) func TestLoadWITPaths(t *testing.T) { - root := t.TempDir() + // Relative paths require the fixture and working directory to share a volume. + root, err := os.MkdirTemp(".", ".wit-load-paths-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + t.Error(err) + } + }) + root, err = filepath.Abs(root) + if err != nil { + t.Fatal(err) + } file := filepath.Join(root, "nested", "example.wit") if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { t.Fatal(err)