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.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..b577f009 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,34 @@ func TestPackagePath(t *testing.T) { t.Errorf("PackagePath(%q): expected error, got nil", tmp) } } + +func TestPackagePathNestedModule(t *testing.T) { + 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 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) { + 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) + } + 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) + } +} diff --git a/internal/witcli/dir_test.go b/internal/witcli/dir_test.go index c3ad69c2..44188bdd 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" @@ -11,9 +12,13 @@ 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) if err := os.Mkdir(dir, os.ModeDir|os.ModePerm); err != nil { t.Fatal(err) } @@ -47,9 +52,20 @@ 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) + 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 +75,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 +92,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 lacks recursive removal on Windows and WASI; 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 +120,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 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..08a73086 --- /dev/null +++ b/wit/load_test.go @@ -0,0 +1,83 @@ +//go:build !tinygo + +package wit + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadWITPaths(t *testing.T) { + // 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) + } + 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) + } + }) + } +}