From 75a58d869d7642f38be41b26e8a5ed9734b7a292 Mon Sep 17 00:00:00 2001 From: andreatp Date: Wed, 4 Nov 2026 18:27:48 +0000 Subject: [PATCH 1/4] Handle Windows separators and roots in guest paths --- .../run/endive/wasi/WasiPreview1Test.java | 28 +++++++++++++++++++ .../java/run/endive/wasi/WasiPreview1.java | 7 +++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java index ce214907d..f180d48e4 100644 --- a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java +++ b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java @@ -570,6 +570,34 @@ public void pathRenameAcrossFileSystemsReturnsExdev() throws IOException { } } + @Test + public void windowsPathsStayInTheDirectory() throws IOException { + try (var fs = ZeroFs.newFileSystem(Configuration.windows())) { + Path dir = java.nio.file.Files.createDirectories(fs.getPath("C:\\work\\dir")); + writeString(dir.resolve("file.txt"), "hello"); + writeString(fs.getPath("C:\\work\\outside.txt"), "outside"); + try (var wasi = wasiWithDirectory("dir", dir)) { + var memory = new ByteBufferMemory(new MemoryLimits(1)); + for (String path : + new String[] { + "../outside.txt", + "..\\outside.txt", + "sub\\..\\..\\outside.txt", + "C:\\work\\outside.txt" + }) { + assertEquals( + WasiErrno.EPERM.value(), + wasi.pathOpen(memory, 3, 0, path, 0, 0, 0, 0, 0), + path); + } + assertEquals( + WasiErrno.ESUCCESS.value(), + wasi.pathOpen(memory, 3, 0, "sub\\..\\file.txt", 0, 0, 0, 0, 0)); + assertEquals(WasiErrno.EACCES.value(), wasi.pathSymlink("..\\outside.txt", 3, "l")); + } + } + } + @Test public void emptyPathReturnsEnoent() throws IOException { Path dir = java.nio.file.Files.createTempDirectory("wasi-empty-path-test"); diff --git a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java index e05c6c7cc..44da8bf9f 100644 --- a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java +++ b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java @@ -1915,12 +1915,13 @@ private static Path resolvePath(Path directory, String rawPathString) { return null; } - if (rawPath.isAbsolute()) { + // on Windows a root can also be "\" or a drive + if (rawPath.getRoot() != null) { return null; } - String normalized = rawPath.normalize().toString(); - if (normalized.equals("..") || normalized.startsWith("../")) { + Path normalized = rawPath.normalize(); + if (normalized.getNameCount() > 0 && normalized.getName(0).toString().equals("..")) { return null; } From cb994fbf8f9ea1900230330db51b3fcc828aa04b Mon Sep 17 00:00:00 2001 From: andreatp Date: Wed, 4 Nov 2026 17:32:29 +0000 Subject: [PATCH 2/4] Return ENOTEMPTY when renaming onto a non-empty directory --- .../main/java/run/endive/wasi/WasiPreview1.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java index 44da8bf9f..17032f395 100644 --- a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java +++ b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java @@ -1516,11 +1516,23 @@ public int pathRename( } catch (DirectoryNotEmptyException e) { return wasiResult(WasiErrno.ENOTEMPTY); } catch (IOException e) { - return wasiResult(WasiErrno.EIO); + // the default provider reports a non-empty target as a generic exception + return wasiResult(isNonEmptyDirectory(newPath) ? WasiErrno.ENOTEMPTY : WasiErrno.EIO); } return wasiResult(WasiErrno.ESUCCESS); } + private static boolean isNonEmptyDirectory(Path path) { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(path)) { + return entries.findAny().isPresent(); + } catch (IOException e) { + return false; + } + } + @WasmExport public int pathSymlink(@Buffer String oldRawPath, int dirFd, @Buffer String newRawPath) { logger.tracef("path_symlink: [\"%s\", %s, \"%s\"]", oldRawPath, dirFd, newRawPath); From c9ca3d85f733de7b4e1438a80129c93427e22cba Mon Sep 17 00:00:00 2001 From: andreatp Date: Wed, 4 Nov 2026 18:31:49 +0000 Subject: [PATCH 3/4] Allow creating files in a preopened directory reached through a symlink --- .../run/endive/wasi/WasiPreview1Test.java | 22 +++++++++++++++++++ .../java/run/endive/wasi/WasiPreview1.java | 19 +++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java index f180d48e4..1b9843310 100644 --- a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java +++ b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java @@ -501,6 +501,28 @@ public void filestatWithoutUnixAttributeView() throws IOException { } } + @Test + public void createFileInSymlinkedDirectory(@TempDir Path tempDir) throws IOException { + Path real = createDirectory(tempDir.resolve("real")); + Path link = createSymbolicLink(tempDir.resolve("link"), real); + try (var wasi = wasiWithDirectory("dir", link)) { + var memory = new ByteBufferMemory(new MemoryLimits(1)); + assertEquals( + WasiErrno.ESUCCESS.value(), + wasi.pathOpen( + memory, + 3, + WasiLookupFlags.SYMLINK_FOLLOW, + "new.txt", + WasiOpenFlags.CREAT, + WasiRights.FD_WRITE, + 0, + 0, + 0)); + assertTrue(exists(real.resolve("new.txt"))); + } + } + @Test public void symlinkEscapeShouldReturnEacces() throws IOException { Path tmpDir = java.nio.file.Files.createTempDirectory("wasi-symlink-test"); diff --git a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java index 17032f395..2dc6aa9bc 100644 --- a/wasi/src/main/java/run/endive/wasi/WasiPreview1.java +++ b/wasi/src/main/java/run/endive/wasi/WasiPreview1.java @@ -1136,9 +1136,7 @@ private ResolvedSymlink resolveSymlinks(Path path, Path directory) { try { Path dirReal = directory.toRealPath(); - Path pathReal = - Files.exists(path) ? path.toRealPath() : path.toAbsolutePath().normalize(); - if (!pathReal.startsWith(dirReal)) { + if (!realPath(path).startsWith(dirReal)) { return new ResolvedSymlink(wasiResult(WasiErrno.EACCES)); } } catch (IOException e) { @@ -1148,6 +1146,21 @@ private ResolvedSymlink resolveSymlinks(Path path, Path directory) { return new ResolvedSymlink(path); } + // The real path of the nearest existing ancestor, followed by the missing names. + private static Path realPath(Path path) throws IOException { + Path existing = path.toAbsolutePath().normalize(); + Path missing = existing.getFileSystem().getPath(""); + while (!Files.exists(existing)) { + Path parent = existing.getParent(); + if (parent == null) { + return path.toAbsolutePath().normalize(); + } + missing = existing.getFileName().resolve(missing); + existing = parent; + } + return existing.toRealPath().resolve(missing); + } + @WasmExport public int pathLink( int oldFd, From 411b5f906d967cf1bbe2f5d75e19e59ab9147bec Mon Sep 17 00:00:00 2001 From: andreatp Date: Wed, 4 Nov 2026 17:32:29 +0000 Subject: [PATCH 4/4] Run the WASI testsuite on the host file system --- .../run/endive/maven/WasiTestGenMojo.java | 27 ++++- wasi-tests/pom.xml | 30 +++++ .../run/endive/wasi/WasiPreview1Test.java | 108 +++++++++-------- .../java/run/endive/wasi/WasiTestRunner.java | 111 ++++++++++++------ 4 files changed, 183 insertions(+), 93 deletions(-) diff --git a/wasi-test-gen-plugin/src/main/java/run/endive/maven/WasiTestGenMojo.java b/wasi-test-gen-plugin/src/main/java/run/endive/maven/WasiTestGenMojo.java index ac06bc024..5a41fbabd 100644 --- a/wasi-test-gen-plugin/src/main/java/run/endive/maven/WasiTestGenMojo.java +++ b/wasi-test-gen-plugin/src/main/java/run/endive/maven/WasiTestGenMojo.java @@ -43,6 +43,7 @@ public class WasiTestGenMojo extends AbstractMojo { private static final String JUNIT_TEST = "org.junit.jupiter.api.Test"; + private static final String JUNIT_TEMP_DIR = "org.junit.jupiter.api.io.TempDir"; private static final String WASI_TEST_RUNNER = "run.endive.wasi.WasiTestRunner"; private final Log log = new SystemStreamLog(); @@ -85,6 +86,12 @@ public class WasiTestGenMojo extends AbstractMojo { @Parameter(property = "wasi-test-gen.skip", defaultValue = "false") private boolean skip; + /** + * Run the tests on a JUnit temporary directory of the host file system. + */ + @Parameter(defaultValue = "false") + private boolean hostFileSystem; + /** * The current Maven project. */ @@ -158,9 +165,17 @@ public void execute() throws MojoExecutionException { cu.addImport("java.util.Map"); cu.addImport("java.util.Optional"); cu.addImport(JUNIT_TEST); + if (hostFileSystem) { + cu.addImport("java.nio.file.Path"); + cu.addImport(JUNIT_TEMP_DIR); + } // generate test methods - var testClass = cu.addClass("Suite" + capitalize(testSuite) + "Test"); + var testClass = + cu.addClass( + "Suite" + + capitalize(testSuite) + + (hostFileSystem ? "HostTest" : "Test")); for (File file : files) { String baseName = file.getName().substring(0, file.getName().length() - ".wasm".length()); @@ -172,6 +187,9 @@ public void execute() throws MojoExecutionException { testClass.addMethod( "test" + escapedCamelCase(baseName), Modifier.Keyword.PUBLIC); method.addAnnotation("Test"); + if (hostFileSystem) { + method.addAndGetParameter("Path", "tempDir").addMarkerAnnotation("TempDir"); + } method.getBody() .orElseThrow() @@ -207,8 +225,11 @@ public void execute() throws MojoExecutionException { AssignExpr.Operator.ASSIGN)) .addStatement( new NameExpr( - "WasiTestRunner.execute(" - + "test, args, root, env, exitCode, stdout)")); + hostFileSystem + ? "WasiTestRunner.executeOnHost(tempDir, " + + "test, args, root, env, exitCode, stdout)" + : "WasiTestRunner.execute(test, args, root, env," + + " exitCode, stdout)")); } dest.add( diff --git a/wasi-tests/pom.xml b/wasi-tests/pom.xml index 6f7a75be2..f4477ea82 100644 --- a/wasi-tests/pom.xml +++ b/wasi-tests/pom.xml @@ -119,4 +119,34 @@ + + + + + host-file-system + + [25,) + + + + + run.endive + wasi-test-gen-plugin + ${project.version} + + + host + + wasi-test-gen + + + true + + + + + + + + diff --git a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java index 1b9843310..ecb60386d 100644 --- a/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java +++ b/wasi-tests/src/test/java/run/endive/wasi/WasiPreview1Test.java @@ -443,61 +443,67 @@ public void filestatWithoutUnixAttributeView() throws IOException { }; for (Configuration configuration : configurations) { try (var fs = ZeroFs.newFileSystem(configuration)) { - Path root = fs.getPath("test"); - createDirectory(root); - writeString(root.resolve("file"), "hello"); - createDirectory(root.resolve("dir")); - try (var wasi = wasiWithDirectory("test", root)) { - var memory = new ByteBufferMemory(new MemoryLimits(1)); - int buf = 0; + assertFileStat(fs.getPath("test")); + } + } + } - assertEquals( - WasiErrno.ESUCCESS.value(), - wasi.pathFilestatGet(memory, 3, 0, "file", buf)); - assertEquals(WasiFileType.REGULAR_FILE.value(), memory.read(buf + 16)); - assertEquals(5, memory.readLong(buf + 32)); - long mtime = getLastModifiedTime(root.resolve("file")).to(NANOSECONDS); - assertEquals(mtime, memory.readLong(buf + 48)); - long fileIno = memory.readLong(buf + 8); - assertNotEquals(0, fileIno); + @Test + public void filestatOnHostFileSystem(@TempDir Path tempDir) throws IOException { + assertFileStat(tempDir.resolve("test")); + } - assertEquals( - WasiErrno.ESUCCESS.value(), - wasi.pathFilestatGet(memory, 3, 0, "dir", buf)); - long dirIno = memory.readLong(buf + 8); - assertNotEquals(0, dirIno); - assertNotEquals(fileIno, dirIno); + private static void assertFileStat(Path root) throws IOException { + createDirectory(root); + writeString(root.resolve("file"), "hello"); + createDirectory(root.resolve("dir")); + try (var wasi = wasiWithDirectory("test", root)) { + var memory = new ByteBufferMemory(new MemoryLimits(1)); + int buf = 0; - int fdPtr = 4096; - assertEquals( - WasiErrno.ESUCCESS.value(), - wasi.pathOpen(memory, 3, 0, "file", 0, 0, 0, 0, fdPtr)); - int fd = memory.readInt(fdPtr); - assertEquals(WasiErrno.ESUCCESS.value(), wasi.fdFilestatGet(memory, fd, buf)); - assertEquals(WasiFileType.REGULAR_FILE.value(), memory.read(buf + 16)); - assertEquals(5, memory.readLong(buf + 32)); - assertEquals(fileIno, memory.readLong(buf + 8)); - - int bufUsedPtr = 2048; - assertEquals( - WasiErrno.ESUCCESS.value(), - wasi.fdReaddir(memory, 3, buf, 1024, 0, bufUsedPtr)); - Map types = new HashMap<>(); - Map inodes = new HashMap<>(); - int used = memory.readInt(bufUsedPtr); - for (int pos = buf; pos < buf + used; ) { - int nameLen = memory.readInt(pos + 16); - String name = memory.readString(pos + 24, nameLen); - types.put(name, (int) memory.read(pos + 20)); - inodes.put(name, memory.readLong(pos + 8)); - pos += 24 + nameLen; - } - assertEquals(WasiFileType.REGULAR_FILE.value(), types.get("file")); - assertEquals(WasiFileType.DIRECTORY.value(), types.get("dir")); - assertEquals(fileIno, inodes.get("file")); - assertEquals(dirIno, inodes.get("dir")); - } + assertEquals( + WasiErrno.ESUCCESS.value(), wasi.pathFilestatGet(memory, 3, 0, "file", buf)); + assertEquals(WasiFileType.REGULAR_FILE.value(), memory.read(buf + 16)); + assertEquals(5, memory.readLong(buf + 32)); + long mtime = getLastModifiedTime(root.resolve("file")).to(NANOSECONDS); + assertEquals(mtime, memory.readLong(buf + 48)); + long fileIno = memory.readLong(buf + 8); + assertNotEquals(0, fileIno); + + assertEquals( + WasiErrno.ESUCCESS.value(), wasi.pathFilestatGet(memory, 3, 0, "dir", buf)); + long dirIno = memory.readLong(buf + 8); + assertNotEquals(0, dirIno); + assertNotEquals(fileIno, dirIno); + + int fdPtr = 4096; + assertEquals( + WasiErrno.ESUCCESS.value(), + wasi.pathOpen(memory, 3, 0, "file", 0, 0, 0, 0, fdPtr)); + int fd = memory.readInt(fdPtr); + assertEquals(WasiErrno.ESUCCESS.value(), wasi.fdFilestatGet(memory, fd, buf)); + assertEquals(WasiFileType.REGULAR_FILE.value(), memory.read(buf + 16)); + assertEquals(5, memory.readLong(buf + 32)); + assertEquals(fileIno, memory.readLong(buf + 8)); + + int bufUsedPtr = 2048; + assertEquals( + WasiErrno.ESUCCESS.value(), + wasi.fdReaddir(memory, 3, buf, 1024, 0, bufUsedPtr)); + Map types = new HashMap<>(); + Map inodes = new HashMap<>(); + int used = memory.readInt(bufUsedPtr); + for (int pos = buf; pos < buf + used; ) { + int nameLen = memory.readInt(pos + 16); + String name = memory.readString(pos + 24, nameLen); + types.put(name, (int) memory.read(pos + 20)); + inodes.put(name, memory.readLong(pos + 8)); + pos += 24 + nameLen; } + assertEquals(WasiFileType.REGULAR_FILE.value(), types.get("file")); + assertEquals(WasiFileType.DIRECTORY.value(), types.get("dir")); + assertEquals(fileIno, inodes.get("file")); + assertEquals(dirIno, inodes.get("dir")); } } diff --git a/wasi-tests/src/test/java/run/endive/wasi/WasiTestRunner.java b/wasi-tests/src/test/java/run/endive/wasi/WasiTestRunner.java index bc08910a6..84813d888 100644 --- a/wasi-tests/src/test/java/run/endive/wasi/WasiTestRunner.java +++ b/wasi-tests/src/test/java/run/endive/wasi/WasiTestRunner.java @@ -1,6 +1,7 @@ package run.endive.wasi; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static run.endive.wasi.Files.copyDirectory; import io.roastedroot.zerofs.Configuration; @@ -11,6 +12,7 @@ import java.nio.file.FileSystem; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -21,6 +23,7 @@ public final class WasiTestRunner { private static final SystemLogger LOGGER = new SystemLogger(); + private static final boolean WINDOWS = System.getProperty("os.name").startsWith("Windows"); private WasiTestRunner() {} @@ -31,60 +34,90 @@ public static void execute( Map env, int exitCode, Optional stdout) { - try (FileSystem fs = ZeroFs.newFileSystem( Configuration.unix().toBuilder().setAttributeViews("unix").build())) { + run(test, args, root, fs.getPath("/"), env, exitCode, stdout); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } - var stdoutStream = new MockPrintStream(); - var stderrStream = new MockPrintStream(); + public static void executeOnHost( + Path tempDir, + File test, + List args, + Optional root, + Map env, + int exitCode, + Optional stdout) { + Map hostEnv = env; + if (WINDOWS && root.isPresent()) { + // Java does not expose Windows file IDs, so hard links get different inode numbers + assumeFalse(test.getName().equals("path_link.wasm"), "hard link inode numbers"); + hostEnv = new HashMap<>(env); + hostEnv.put("NO_RENAME_DIR_TO_EMPTY_DIR", "true"); + } + run(test, args, root, tempDir.resolve("root"), hostEnv, exitCode, stdout); + } + + private static void run( + File test, + List args, + Optional root, + Path target, + Map env, + int exitCode, + Optional stdout) { + var stdoutStream = new MockPrintStream(); + var stderrStream = new MockPrintStream(); - List allArgs = new ArrayList<>(); - allArgs.add("test"); - allArgs.addAll(args); + List allArgs = new ArrayList<>(); + allArgs.add("test"); + allArgs.addAll(args); - WasiOptions.Builder options = - WasiOptions.builder() - .withStdout(stdoutStream) - .withStderr(stderrStream) - .withArguments(allArgs); + WasiOptions.Builder options = + WasiOptions.builder() + .withStdout(stdoutStream) + .withStderr(stderrStream) + .withArguments(allArgs); - env.forEach(options::withEnvironment); - // TODO: dangling filesystem is not supported - if (!test.getName().contains("environ")) { - options.withEnvironment("NO_DANGLING_FILESYSTEM", "true"); - } + env.forEach(options::withEnvironment); + // TODO: dangling filesystem is not supported + if (!test.getName().contains("environ")) { + options.withEnvironment("NO_DANGLING_FILESYSTEM", "true"); + } - if (root.isPresent()) { - Path source = test.getParentFile().toPath().resolve(root.get()); - Path target = fs.getPath("/"); + if (root.isPresent()) { + Path source = test.getParentFile().toPath().resolve(root.get()); + try { copyDirectory(source, target); - options.withDirectory("/", target); + } catch (IOException e) { + throw new UncheckedIOException(e); } + options.withDirectory("/", target); + } - int actualExitCode; - try { - actualExitCode = execute(test, options.build()); - } catch (WasiExitException e) { - actualExitCode = e.exitCode(); - } catch (RuntimeException e) { - String message = "Failed to execute test: " + test; - if (!stdoutStream.output().isEmpty() || !stderrStream.output().isEmpty()) { - message += "\n<<<<<\n"; - message += (stdoutStream.output() + stderrStream.output()).strip(); - message += "\n>>>>>"; - } - throw new RuntimeException(message, e); + int actualExitCode; + try { + actualExitCode = runModule(test, options.build()); + } catch (WasiExitException e) { + actualExitCode = e.exitCode(); + } catch (RuntimeException e) { + String message = "Failed to execute test: " + test; + if (!stdoutStream.output().isEmpty() || !stderrStream.output().isEmpty()) { + message += "\n<<<<<\n"; + message += (stdoutStream.output() + stderrStream.output()).strip(); + message += "\n>>>>>"; } - - assertEquals(exitCode, actualExitCode, "exit code"); - stdout.ifPresent(expected -> assertEquals(expected, stdoutStream.output(), "stdout")); - } catch (IOException e) { - throw new UncheckedIOException(e); + throw new RuntimeException(message, e); } + + assertEquals(exitCode, actualExitCode, "exit code"); + stdout.ifPresent(expected -> assertEquals(expected, stdoutStream.output(), "stdout")); } - private static int execute(File test, WasiOptions wasiOptions) { + private static int runModule(File test, WasiOptions wasiOptions) { try (var wasi = WasiPreview1.builder().withLogger(LOGGER).withOptions(wasiOptions).build()) { Instance.builder(Parser.parse(test))