[FS] Fix open() ignoring trailing slash on regular files

Fixes: #26710
diff --git a/src/lib/libfs.js b/src/lib/libfs.js
index 08656c3..1b958df 100644
--- a/src/lib/libfs.js
+++ b/src/lib/libfs.js
@@ -179,6 +179,8 @@
         path = FS.cwd() + '/' + path;
       }
 
+      var hasTrailingSlash = path.endsWith('/');
+
       // limit max consecutive symlinks to SYMLOOP_MAX.
       linkloop: for (var nlinks = 0; nlinks < {{{ cDefs.SYMLOOP_MAX }}}; nlinks++) {
         // split the absolute path
@@ -196,10 +198,16 @@
           }
 
           if (parts[i] === '.') {
+            if (!FS.isDir(current.mode)) {
+              throw new FS.ErrnoError({{{ cDefs.ENOTDIR }}});
+            }
             continue;
           }
 
           if (parts[i] === '..') {
+            if (!FS.isDir(current.mode)) {
+              throw new FS.ErrnoError({{{ cDefs.ENOTDIR }}});
+            }
             current_path = PATH.dirname(current_path);
             if (FS.isRoot(current)) {
               path = current_path + '/' + parts.slice(i + 1).join('/');
@@ -232,8 +240,9 @@
           }
 
           // by default, lookupPath will not follow a symlink if it is the final path component.
-          // setting opts.follow = true will override this behavior.
-          if (FS.isLink(current.mode) && (!islast || opts.follow)) {
+          // setting opts.follow = true or having a trailing slash will override this behavior
+          // (POSIX requires that a trailing slash forces following of symbolic links).
+          if (FS.isLink(current.mode) && (!islast || opts.follow || hasTrailingSlash)) {
             if (!current.node_ops.readlink) {
               throw new FS.ErrnoError({{{ cDefs.ENOSYS }}});
             }
@@ -241,10 +250,16 @@
             if (!PATH.isAbs(link)) {
               link = PATH.dirname(current_path) + '/' + link;
             }
-            path = link + '/' + parts.slice(i + 1).join('/');
+            var suffix = parts.slice(i + 1).join('/');
+            path = link + (suffix ? '/' + suffix : '');
             continue linkloop;
           }
         }
+        // POSIX requires that a pathname with a trailing slash must refer to a
+        // directory.
+        if (hasTrailingSlash && !FS.isDir(current.mode)) {
+          throw new FS.ErrnoError({{{ cDefs.ENOTDIR }}});
+        }
         return { path: current_path, node: current };
       }
       throw new FS.ErrnoError({{{ cDefs.ELOOP }}});
diff --git a/system/lib/wasmfs/js_api.cpp b/system/lib/wasmfs/js_api.cpp
index 5330b66..11943af 100644
--- a/system/lib/wasmfs/js_api.cpp
+++ b/system/lib/wasmfs/js_api.cpp
@@ -69,7 +69,7 @@
   if (parsedParent.getError()) {
     return 0;
   }
-  auto& [parent, childNameView] = parsedParent.getParentChild();
+  auto& [parent, childNameView, hasTrailingSlash] = parsedParent.getParentChild();
   std::string childName(childNameView);
 
   std::shared_ptr<File> child;
@@ -77,6 +77,11 @@
     auto lockedParent = parent->locked();
     child = lockedParent.getChild(childName);
     if (!child) {
+      // POSIX requires that a pathname with a trailing slash must refer to a
+      // directory. If it doesn't exist, we can't create it as a directory here.
+      if (hasTrailingSlash) {
+        return 0;
+      }
       // Lookup failed; try creating the file.
       child = lockedParent.insertDataFile(childName, 0777);
       if (!child) {
@@ -86,6 +91,12 @@
     }
   }
 
+  // POSIX requires that a pathname with a trailing slash must refer to a
+  // directory.
+  if (hasTrailingSlash && !child->is<Directory>()) {
+    return 0;
+  }
+
   auto dataFile = child->dynCast<DataFile>();
   if (!dataFile) {
     // There is something here but it isn't a data file.
diff --git a/system/lib/wasmfs/paths.cpp b/system/lib/wasmfs/paths.cpp
index 3d1fbf4..b7b9a28 100644
--- a/system/lib/wasmfs/paths.cpp
+++ b/system/lib/wasmfs/paths.cpp
@@ -75,16 +75,18 @@
     path.remove_prefix(1);
   }
 
+  bool hasTrailingSlash = false;
   // Ignore trailing '/'.
   while (!path.empty() && path.back() == '/') {
     path.remove_suffix(1);
+    hasTrailingSlash = true;
   }
 
   // An empty path here means that the path was equivalent to "/" and does not
   // contain a child segment for us to return. The root is its own parent, so we
   // can handle this by returning (root, ".").
   if (path.empty()) {
-    return {std::make_pair(std::move(curr), std::string_view("."))};
+    return {std::move(curr), ".", hasTrailingSlash};
   }
 
   while (true) {
@@ -96,7 +98,7 @@
     // If this is the leaf segment, return.
     size_t segment_end = path.find_first_of('/');
     if (segment_end == std::string_view::npos) {
-      return {std::make_pair(std::move(curr), path)};
+      return {std::move(curr), path, hasTrailingSlash};
     }
 
     // Try to descend into the child segment.
@@ -122,8 +124,25 @@
   if (auto err = parsed.getError()) {
     return {err};
   }
-  auto& [parent, child] = parsed.getParentChild();
-  return getChild(parent, child, links, recursions);
+  auto& [parent, child, hasTrailingSlash] = parsed.getParentChild();
+
+  // POSIX requires that a trailing slash forces following of symbolic links.
+  if (hasTrailingSlash) {
+    links = FollowLinks;
+  }
+
+  auto file = getChild(parent, child, links, recursions);
+  if (auto err = file.getError()) {
+    return err;
+  }
+
+  // POSIX requires that a pathname with a trailing slash must refer to a
+  // directory.
+  if (hasTrailingSlash && file.getFile()->kind != File::DirectoryKind) {
+    return -ENOTDIR;
+  }
+
+  return file;
 }
 
 } // anonymous namespace
diff --git a/system/lib/wasmfs/paths.h b/system/lib/wasmfs/paths.h
index 353c55a..aae3f31 100644
--- a/system/lib/wasmfs/paths.h
+++ b/system/lib/wasmfs/paths.h
@@ -21,7 +21,11 @@
 // The parent directory and the name of an entry within it. The returned string
 // view is either backed by the same memory as the view passed to `parseParent`
 // or is a view into a static string.
-using ParentChild = std::pair<std::shared_ptr<Directory>, std::string_view>;
+struct ParentChild {
+  std::shared_ptr<Directory> parent;
+  std::string_view child;
+  bool hasTrailingSlash;
+};
 
 // If the path refers to a link, whether we should follow that link. Links among
 // the parent directories in the path are always followed.
@@ -33,9 +37,13 @@
 
 public:
   ParsedParent(Error err) : val(err) {}
-  ParsedParent(ParentChild pair) : val(pair) {}
+  ParsedParent(ParentChild pc) : val(pc) {}
+  ParsedParent(std::shared_ptr<Directory> parent,
+               std::string_view child,
+               bool hasTrailingSlash)
+    : val(ParentChild{parent, child, hasTrailingSlash}) {}
   // Always ok to call, returns 0 if there is no error.
-  long getError() {
+  long getError() const {
     if (auto* err = std::get_if<Error>(&val)) {
       assert(*err != 0 && "Unexpected zero error value");
       return *err;
@@ -43,7 +51,7 @@
     return 0;
   }
   // Call only after checking for an error.
-  ParentChild& getParentChild() {
+  const ParentChild& getParentChild() const {
     auto* ptr = std::get_if<ParentChild>(&val);
     assert(ptr && "Unhandled path parsing error!");
     return *ptr;
diff --git a/system/lib/wasmfs/syscalls.cpp b/system/lib/wasmfs/syscalls.cpp
index 2a86a58..8ea0253 100644
--- a/system/lib/wasmfs/syscalls.cpp
+++ b/system/lib/wasmfs/syscalls.cpp
@@ -433,7 +433,7 @@
   if (auto err = parsed.getError()) {
     return err;
   }
-  auto& [parent, childName] = parsed.getParentChild();
+  auto& [parent, childName, hasTrailingSlash] = parsed.getParentChild();
   if (childName.size() > WASMFS_NAME_MAX) {
     return -ENAMETOOLONG;
   }
@@ -444,6 +444,15 @@
     child = lockedParent.getChild(std::string(childName));
     // The requested node was not found.
     if (!child) {
+      // POSIX requires that a pathname with a trailing slash must refer to a
+      // directory. If it doesn't exist, we can't create it as a directory
+      // here (open() can only create regular files).
+      if (hasTrailingSlash) {
+        if (flags & O_CREAT) {
+          return -EISDIR;
+        }
+        return -ENOENT;
+      }
       // If curr is the last element and the create flag is specified
       // If O_DIRECTORY is also specified, still create a regular file:
       // https://man7.org/linux/man-pages/man2/open.2.html#BUGS
@@ -534,8 +543,9 @@
     return -EACCES;
   }
 
-  // Fail if O_DIRECTORY is specified and pathname is not a directory
-  if (flags & O_DIRECTORY && !child->is<Directory>()) {
+  // Fail if O_DIRECTORY (or a trailing slash is specified) and pathname is not
+  // a directory.
+  if ((hasTrailingSlash || (flags & O_DIRECTORY)) && !child->is<Directory>()) {
     return -ENOTDIR;
   }
 
@@ -602,7 +612,7 @@
   if (auto err = parsed.getError()) {
     return err;
   }
-  auto& [parent, childNameView] = parsed.getParentChild();
+  auto& [parent, childNameView, hasTrailingSlash] = parsed.getParentChild();
   std::string childName(childNameView);
   auto lockedParent = parent->locked();
 
@@ -611,7 +621,11 @@
   }
 
   // Check if the requested directory already exists.
-  if (lockedParent.getChild(childName)) {
+  auto child = lockedParent.getChild(childName);
+  if (child) {
+    if (hasTrailingSlash && !child->is<Directory>()) {
+      return -ENOTDIR;
+    }
     return -EEXIST;
   }
 
@@ -831,13 +845,18 @@
   if (auto err = parsed.getError()) {
     return err;
   }
-  auto& [parent, childNameView] = parsed.getParentChild();
+  auto& [parent, childNameView, hasTrailingSlash] = parsed.getParentChild();
   std::string childName(childNameView);
   auto lockedParent = parent->locked();
   auto file = lockedParent.getChild(childName);
   if (!file) {
     return -ENOENT;
   }
+
+  if (hasTrailingSlash && !file->is<Directory>()) {
+    return -ENOTDIR;
+  }
+
   // Disallow removing the root directory, even if it is empty.
   if (file == wasmFS.getRootDirectory()) {
     return -EBUSY;
@@ -879,13 +898,18 @@
   if (auto err = parsed.getError()) {
     return err;
   }
-  auto& [parent, childNameView] = parsed.getParentChild();
+  auto& [parent, childNameView, hasTrailingSlash] = parsed.getParentChild();
   std::string childName(childNameView);
   auto lockedParent = parent->locked();
   auto file = lockedParent.getChild(childName);
   if (!file) {
     return -ENOENT;
   }
+
+  if (hasTrailingSlash && !file->is<Directory>()) {
+    return -ENOTDIR;
+  }
+
   // Disallow removing the root directory, even if it is empty.
   if (file == wasmFS.getRootDirectory()) {
     return -EBUSY;
@@ -993,7 +1017,8 @@
   if (auto err = parsedOld.getError()) {
     return err;
   }
-  auto& [oldParent, oldFileNameView] = parsedOld.getParentChild();
+  auto& [oldParent, oldFileNameView, oldHasTrailingSlash] =
+    parsedOld.getParentChild();
   std::string oldFileName(oldFileNameView);
 
   // Get the new directory.
@@ -1001,7 +1026,8 @@
   if (auto err = parsedNew.getError()) {
     return err;
   }
-  auto& [newParent, newFileNameView] = parsedNew.getParentChild();
+  auto& [newParent, newFileNameView, newHasTrailingSlash] =
+    parsedNew.getParentChild();
   std::string newFileName(newFileNameView);
 
   if (newFileNameView.size() > WASMFS_NAME_MAX) {
@@ -1020,6 +1046,14 @@
     return -ENOENT;
   }
 
+  if (oldHasTrailingSlash && !oldFile->is<Directory>()) {
+    return -ENOTDIR;
+  }
+
+  if (newHasTrailingSlash && newFile && !newFile->is<Directory>()) {
+    return -ENOTDIR;
+  }
+
   // If the source and destination are the same, do nothing.
   if (oldFile == newFile) {
     return 0;
@@ -1083,10 +1117,15 @@
   if (auto err = parsed.getError()) {
     return err;
   }
-  auto& [parent, childNameView] = parsed.getParentChild();
+  auto& [parent, childNameView, hasTrailingSlash] = parsed.getParentChild();
   if (childNameView.size() > WASMFS_NAME_MAX) {
     return -ENAMETOOLONG;
   }
+
+  if (hasTrailingSlash) {
+    return -ENOENT;
+  }
+
   auto lockedParent = parent->locked();
   std::string childName(childNameView);
   if (lockedParent.getChild(childName)) {
diff --git a/system/lib/wasmfs/wasmfs.cpp b/system/lib/wasmfs/wasmfs.cpp
index 7a771a8..d108d85 100644
--- a/system/lib/wasmfs/wasmfs.cpp
+++ b/system/lib/wasmfs/wasmfs.cpp
@@ -189,7 +189,7 @@
       emscripten_err("Fatal error during file preloading");
       abort();
     }
-    auto& [parent, childName] = parsed.getParentChild();
+    auto& [parent, childName, hasTrailingSlash] = parsed.getParentChild();
     auto created =
       parent->locked().insertDataFile(std::string(childName), (mode_t)mode);
     assert(created && "TODO: handle preload insertion errors");
diff --git a/test/codesize/test_codesize_cxx_ctors1.json b/test/codesize/test_codesize_cxx_ctors1.json
index 2c79cb1..038b26e 100644
--- a/test/codesize/test_codesize_cxx_ctors1.json
+++ b/test/codesize/test_codesize_cxx_ctors1.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 19260,
-  "a.out.js.gz": 7991,
+  "a.out.js": 19296,
+  "a.out.js.gz": 8030,
   "a.out.nodebug.wasm": 132627,
   "a.out.nodebug.wasm.gz": 49921,
-  "total": 151887,
-  "total_gz": 57912,
+  "total": 151923,
+  "total_gz": 57951,
   "sent": [
     "__cxa_throw",
     "_abort_js",
diff --git a/test/codesize/test_codesize_cxx_ctors2.json b/test/codesize/test_codesize_cxx_ctors2.json
index 804f183..b80f002 100644
--- a/test/codesize/test_codesize_cxx_ctors2.json
+++ b/test/codesize/test_codesize_cxx_ctors2.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 19237,
-  "a.out.js.gz": 7978,
+  "a.out.js": 19273,
+  "a.out.js.gz": 8017,
   "a.out.nodebug.wasm": 132055,
   "a.out.nodebug.wasm.gz": 49575,
-  "total": 151292,
-  "total_gz": 57553,
+  "total": 151328,
+  "total_gz": 57592,
   "sent": [
     "__cxa_throw",
     "_abort_js",
diff --git a/test/codesize/test_codesize_cxx_except.json b/test/codesize/test_codesize_cxx_except.json
index 4117b5c..70c8b48 100644
--- a/test/codesize/test_codesize_cxx_except.json
+++ b/test/codesize/test_codesize_cxx_except.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 23240,
-  "a.out.js.gz": 8977,
+  "a.out.js": 23277,
+  "a.out.js.gz": 9015,
   "a.out.nodebug.wasm": 172526,
   "a.out.nodebug.wasm.gz": 57447,
-  "total": 195766,
-  "total_gz": 66424,
+  "total": 195803,
+  "total_gz": 66462,
   "sent": [
     "__cxa_begin_catch",
     "__cxa_end_catch",
diff --git a/test/codesize/test_codesize_cxx_except_wasm.json b/test/codesize/test_codesize_cxx_except_wasm.json
index 69dbaf4..b003a8b 100644
--- a/test/codesize/test_codesize_cxx_except_wasm.json
+++ b/test/codesize/test_codesize_cxx_except_wasm.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 19092,
-  "a.out.js.gz": 7925,
+  "a.out.js": 19128,
+  "a.out.js.gz": 7962,
   "a.out.nodebug.wasm": 147926,
   "a.out.nodebug.wasm.gz": 55309,
-  "total": 167018,
-  "total_gz": 63234,
+  "total": 167054,
+  "total_gz": 63271,
   "sent": [
     "_abort_js",
     "_tzset_js",
diff --git a/test/codesize/test_codesize_cxx_except_wasm_legacy.json b/test/codesize/test_codesize_cxx_except_wasm_legacy.json
index 00312e3..461330e 100644
--- a/test/codesize/test_codesize_cxx_except_wasm_legacy.json
+++ b/test/codesize/test_codesize_cxx_except_wasm_legacy.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 19166,
-  "a.out.js.gz": 7949,
+  "a.out.js": 19202,
+  "a.out.js.gz": 7987,
   "a.out.nodebug.wasm": 145732,
   "a.out.nodebug.wasm.gz": 54936,
-  "total": 164898,
-  "total_gz": 62885,
+  "total": 164934,
+  "total_gz": 62923,
   "sent": [
     "_abort_js",
     "_tzset_js",
diff --git a/test/codesize/test_codesize_cxx_lto.json b/test/codesize/test_codesize_cxx_lto.json
index b5022ee..87dddb2 100644
--- a/test/codesize/test_codesize_cxx_lto.json
+++ b/test/codesize/test_codesize_cxx_lto.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 18629,
-  "a.out.js.gz": 7684,
+  "a.out.js": 18665,
+  "a.out.js.gz": 7720,
   "a.out.nodebug.wasm": 101958,
   "a.out.nodebug.wasm.gz": 39461,
-  "total": 120587,
-  "total_gz": 47145,
+  "total": 120623,
+  "total_gz": 47181,
   "sent": [
     "a (emscripten_resize_heap)",
     "b (_setitimer_js)",
diff --git a/test/codesize/test_codesize_cxx_mangle.json b/test/codesize/test_codesize_cxx_mangle.json
index ab6962b..fe22d69 100644
--- a/test/codesize/test_codesize_cxx_mangle.json
+++ b/test/codesize/test_codesize_cxx_mangle.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 23290,
-  "a.out.js.gz": 8999,
+  "a.out.js": 23327,
+  "a.out.js.gz": 9037,
   "a.out.nodebug.wasm": 238957,
   "a.out.nodebug.wasm.gz": 79820,
-  "total": 262247,
-  "total_gz": 88819,
+  "total": 262284,
+  "total_gz": 88857,
   "sent": [
     "__cxa_begin_catch",
     "__cxa_end_catch",
diff --git a/test/codesize/test_codesize_cxx_noexcept.json b/test/codesize/test_codesize_cxx_noexcept.json
index 32250f8..d3605bb 100644
--- a/test/codesize/test_codesize_cxx_noexcept.json
+++ b/test/codesize/test_codesize_cxx_noexcept.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 19260,
-  "a.out.js.gz": 7991,
+  "a.out.js": 19296,
+  "a.out.js.gz": 8030,
   "a.out.nodebug.wasm": 134657,
   "a.out.nodebug.wasm.gz": 50769,
-  "total": 153917,
-  "total_gz": 58760,
+  "total": 153953,
+  "total_gz": 58799,
   "sent": [
     "__cxa_throw",
     "_abort_js",
diff --git a/test/codesize/test_codesize_file_preload.expected.js b/test/codesize/test_codesize_file_preload.expected.js
index 7921688..25581aa 100644
--- a/test/codesize/test_codesize_file_preload.expected.js
+++ b/test/codesize/test_codesize_file_preload.expected.js
@@ -1470,6 +1470,7 @@
     if (!PATH.isAbs(path)) {
       path = FS.cwd() + "/" + path;
     }
+    var hasTrailingSlash = path.endsWith("/");
     // limit max consecutive symlinks to SYMLOOP_MAX.
     linkloop: for (var nlinks = 0; nlinks < 40; nlinks++) {
       // split the absolute path
@@ -1484,9 +1485,15 @@
           break;
         }
         if (parts[i] === ".") {
+          if (!FS.isDir(current.mode)) {
+            throw new FS.ErrnoError(54);
+          }
           continue;
         }
         if (parts[i] === "..") {
+          if (!FS.isDir(current.mode)) {
+            throw new FS.ErrnoError(54);
+          }
           current_path = PATH.dirname(current_path);
           if (FS.isRoot(current)) {
             path = current_path + "/" + parts.slice(i + 1).join("/");
@@ -1518,8 +1525,9 @@
           current = current.mounted.root;
         }
         // by default, lookupPath will not follow a symlink if it is the final path component.
-        // setting opts.follow = true will override this behavior.
-        if (FS.isLink(current.mode) && (!islast || opts.follow)) {
+        // setting opts.follow = true or having a trailing slash will override this behavior
+        // (POSIX requires that a trailing slash forces following of symbolic links).
+        if (FS.isLink(current.mode) && (!islast || opts.follow || hasTrailingSlash)) {
           if (!current.node_ops.readlink) {
             throw new FS.ErrnoError(52);
           }
@@ -1527,10 +1535,16 @@
           if (!PATH.isAbs(link)) {
             link = PATH.dirname(current_path) + "/" + link;
           }
-          path = link + "/" + parts.slice(i + 1).join("/");
+          var suffix = parts.slice(i + 1).join("/");
+          path = link + (suffix ? "/" + suffix : "");
           continue linkloop;
         }
       }
+      // POSIX requires that a pathname with a trailing slash must refer to a
+      // directory.
+      if (hasTrailingSlash && !FS.isDir(current.mode)) {
+        throw new FS.ErrnoError(54);
+      }
       return {
         path: current_path,
         node: current
diff --git a/test/codesize/test_codesize_file_preload.json b/test/codesize/test_codesize_file_preload.json
index 45bd9ba..cfd062b 100644
--- a/test/codesize/test_codesize_file_preload.json
+++ b/test/codesize/test_codesize_file_preload.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 22207,
-  "a.out.js.gz": 9203,
+  "a.out.js": 22341,
+  "a.out.js.gz": 9239,
   "a.out.nodebug.wasm": 1648,
   "a.out.nodebug.wasm.gz": 938,
-  "total": 23855,
-  "total_gz": 10141,
+  "total": 23989,
+  "total_gz": 10177,
   "sent": [
     "a (fd_write)"
   ],
diff --git a/test/codesize/test_codesize_files_js_fs.json b/test/codesize/test_codesize_files_js_fs.json
index fdf2c69..bce450b 100644
--- a/test/codesize/test_codesize_files_js_fs.json
+++ b/test/codesize/test_codesize_files_js_fs.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 17919,
-  "a.out.js.gz": 7341,
+  "a.out.js": 17955,
+  "a.out.js.gz": 7374,
   "a.out.nodebug.wasm": 381,
   "a.out.nodebug.wasm.gz": 260,
-  "total": 18300,
-  "total_gz": 7601,
+  "total": 18336,
+  "total_gz": 7634,
   "sent": [
     "a (fd_write)",
     "b (fd_read)",
diff --git a/test/codesize/test_codesize_hello_dylink.json b/test/codesize/test_codesize_hello_dylink.json
index 2706ec7..70b02a2 100644
--- a/test/codesize/test_codesize_hello_dylink.json
+++ b/test/codesize/test_codesize_hello_dylink.json
@@ -1,10 +1,10 @@
 {
-  "a.out.js": 26251,
-  "a.out.js.gz": 11194,
+  "a.out.js": 26288,
+  "a.out.js.gz": 11248,
   "a.out.nodebug.wasm": 17671,
   "a.out.nodebug.wasm.gz": 8925,
-  "total": 43922,
-  "total_gz": 20119,
+  "total": 43959,
+  "total_gz": 20173,
   "sent": [
     "__syscall_stat64",
     "emscripten_resize_heap",
diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json
index a6ae726..9b12b64 100644
--- a/test/codesize/test_codesize_hello_dylink_all.json
+++ b/test/codesize/test_codesize_hello_dylink_all.json
@@ -1,7 +1,7 @@
 {
-  "a.out.js": 244573,
+  "a.out.js": 244710,
   "a.out.nodebug.wasm": 578054,
-  "total": 822627,
+  "total": 822764,
   "sent": [
     "IMG_Init",
     "IMG_Load",
diff --git a/test/fs/test_fs_enotdir.c b/test/fs/test_fs_enotdir.c
index 0e776a7..c531cdc 100644
--- a/test/fs/test_fs_enotdir.c
+++ b/test/fs/test_fs_enotdir.c
@@ -13,6 +13,16 @@
     assert(close(src_fd) == 0);
   }
   {
+    // POSIX: open("file/") must fail with ENOTDIR if file is a regular file.
+    assert(open("file/", O_RDONLY) == -1);
+    assert(errno == ENOTDIR);
+
+    assert(open("file/.", O_RDONLY) == -1);
+    assert(errno == ENOTDIR);
+
+    assert(open("file/..", O_RDONLY) == -1);
+    assert(errno == ENOTDIR);
+
     assert(mkdir("file/blah", 0777) == -1);
     assert(errno == ENOTDIR);
   }
@@ -20,5 +30,19 @@
     assert(open("./does-not-exist/", O_CREAT, 0777) == -1);
     assert(errno == EISDIR);
   }
+  {
+    assert(mkdir("dir", 0777) == 0);
+    assert(symlink("dir", "link_to_dir") == 0);
+    assert(symlink("file", "link_to_file") == 0);
+
+    // link_to_dir/ should resolve to the directory.
+    int fd = open("link_to_dir/", O_RDONLY);
+    assert(fd >= 0);
+    close(fd);
+
+    // link_to_file/ should fail with ENOTDIR.
+    assert(open("link_to_file/", O_RDONLY) == -1);
+    assert(errno == ENOTDIR);
+  }
   printf("success\n");
 }
diff --git a/test/test_core.py b/test/test_core.py
index 5ad9450..fcfaf52 100644
--- a/test/test_core.py
+++ b/test/test_core.py
@@ -5934,7 +5934,6 @@
   @no_windows('https://github.com/emscripten-core/emscripten/issues/8882')
   @crossplatform
   @also_with_nodefs_both
-  @no_wasmfs('Assertion failed: open("./does-not-exist/", O_CREAT, 0777) == -1 in test_fs_enotdir.c line 20. https://github.com/emscripten-core/emscripten/issues/25035')
   def test_fs_enotdir(self):
     if MACOS and '-DNODERAWFS' in self.cflags:
       self.skipTest('BSD libc sets a different errno')
@@ -6039,7 +6038,6 @@
   @also_with_nodefs_both
   @no_windows("stat ino values don't match on windows")
   @crossplatform
-  @no_wasmfs('Assertion failed: "a_ino == sta.st" in test_fs_readdir_ino_matches_stat_ino.c, line 58. https://github.com/emscripten-core/emscripten/issues/25035')
   def test_fs_readdir_ino_matches_stat_ino(self):
     self.do_runf('fs/test_fs_readdir_ino_matches_stat_ino.c', 'success')
 
diff --git a/test/wasmfs/wasmfs_open.c b/test/wasmfs/wasmfs_open.c
index ac95cbd..585ef32 100644
--- a/test/wasmfs/wasmfs_open.c
+++ b/test/wasmfs/wasmfs_open.c
@@ -15,14 +15,18 @@
 
 // FIXME: Merge with other existing close and open tests.
 
+#ifndef S_IWUGO
+#define S_IWUGO (S_IWUSR | S_IWGRP | S_IWOTH)
+#define S_IRUGO (S_IRUSR | S_IRGRP | S_IROTH)
+#define S_IXUGO (S_IXUSR | S_IXGRP | S_IXOTH)
+#endif
+
 int main() {
-  // Test writing to a file with a trailing slash.
+  // Test that opening a file with a trailing slash fails with ENOTDIR.
+  errno = 0;
   int fd = open("/dev/stdout/", O_WRONLY);
-
-  dprintf(fd, "WORKING WITH TRAILING BACKSLASH\n");
-
-  // Close open file
-  close(fd);
+  assert(fd == -1);
+  assert(errno == ENOTDIR);
 
   // Test writing to a file with no trailing backslash.
   int fd2 = open("/dev/stdout", O_WRONLY);
diff --git a/test/wasmfs/wasmfs_open.out b/test/wasmfs/wasmfs_open.out
index 835911b..f30bc31 100644
--- a/test/wasmfs/wasmfs_open.out
+++ b/test/wasmfs/wasmfs_open.out
@@ -1,4 +1,3 @@
-WORKING WITH TRAILING BACKSLASH
 WORKING WITHOUT TRAILING BACKSLASH
 Errno: Bad file descriptor
 Errno: Is a directory
diff --git a/test/wasmfs/wasmfs_stat.c b/test/wasmfs/wasmfs_stat.c
index 716e90e..94a64bd 100644
--- a/test/wasmfs/wasmfs_stat.c
+++ b/test/wasmfs/wasmfs_stat.c
@@ -27,7 +27,12 @@
 
   // Test opening a file and calling fstat.
   struct stat file;
+  errno = 0;
   int fd = open("/dev/stdout/", O_WRONLY);
+  assert(fd == -1);
+  assert(errno == ENOTDIR);
+
+  fd = open("/dev/stdout", O_WRONLY);
   assert(fd >= 0);
   assert(fstat(fd, &file) != -1);
 
@@ -48,7 +53,7 @@
   close(fd);
 
   // Check to see if the previous inode number matches.
-  int newfd = open("/dev/stdout/", O_WRONLY);
+  int newfd = open("/dev/stdout", O_WRONLY);
   struct stat newFile;
   assert(newfd >= 0);
   assert(fstat(newfd, &newFile) != -1);
@@ -95,7 +100,10 @@
 
   // Test calling stat without opening a file.
   struct stat statFile;
-  assert(stat("/dev/stdout/", &statFile) != -1);
+  errno = 0;
+  assert(stat("/dev/stdout/", &statFile) == -1);
+  assert(errno == ENOTDIR);
+  assert(stat("/dev/stdout", &statFile) != -1);
 
   assert(statFile.st_size == 0);
   assert((statFile.st_mode & S_IFMT) == S_IFCHR);