Skip to content

Commit b1d3d4f

Browse files
xerialclaude
andauthored
fix: Gate POSIX filesystem calls on LinktimeInfo.isWindows in Scala Native impl (#629)
## Summary - `FileSystemNative` in `uni-core` unconditionally references POSIX-only symbols (`readlink`, `symlink`, `stat`/`lstat`, `chmod`, `getpwuid`, `getgrgid`) that MSVC's CRT does not export. Downstream Scala Native + Windows consumers (wvlet's `wvc-lib/wvlet.dll`) fail to link once they exercise this code path. See wvlet run [25527054437](https://github.com/wvlet/wvlet/actions/runs/25527054437/job/74540961637): `LNK2019: unresolved external symbol readlink / scalanative_stat / _lstat / _getpwuid / _getgrgid`. - Gate each POSIX path on `scala.scalanative.meta.LinktimeInfo.isWindows` — a link-time constant, so DCE removes the POSIX branch on Windows binaries and those symbols are never referenced. - Same pattern as #519 (`localtime_r` fix), whose downstream Windows CI confirmed the DCE works end-to-end. ## Windows behavior | Operation | Non-Windows | Windows | |---|---|---| | `isSymlinkPath` | `unistd.readlink` probe | returns `false` (path treated as regular file) | | `doStatFileInfo` | `stat`/`lstat` + `getpwuid`/`getgrgid` | returns `(None, None, None)` | | `createSymlink` / `readSymlink` | `unistd.symlink`/`readlink` | throws `UnsupportedOperationException` | | `setPermissions` | `stat.chmod` | throws `UnsupportedOperationException` | | `permissions` / `owner` / `group` | POSIX values | throws `IOException` (via existing `getOrElse`) | `info(path)` still returns a useful `FileInfo` on Windows: `size`, `lastModified`, `isReadable/Writable/Executable`, `isHidden` all come from `java.io.File` which is supported by Scala Native's javalib on Windows. Symlink detection is skipped (a Windows symlink appears as a regular file), and `permissions`/`owner`/`group` are `None`. ## Test plan - [x] `./sbt coreNative/Compile/compile` — clean - [x] `./sbt uniJVM/testOnly wvlet.uni.io.*` — 140 tests passed - [x] `./sbt uniNative/testOnly wvlet.uni.io.*` — 88 tests passed (POSIX branch) - [x] `./sbt scalafmtCheckAll` — clean - [ ] End-to-end Windows verification: bump `UNI_VERSION` in wvlet after this lands and is published; re-run wvlet's `Build native on Windows (x64/arm64)` job. ## Follow-up wvlet's `native.yml` still needs a separate rename step for `libcurl.lib → curl.lib` (vcpkg's curl port names its import lib `libcurl.lib`, but `@link("curl")` sends `curl.lib` to the linker). Tracked in a wvlet-side PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c7e5cd1 commit b1d3d4f

1 file changed

Lines changed: 82 additions & 50 deletions

File tree

uni-core/.native/src/main/scala/wvlet/uni/io/FileSystemImpl.scala

Lines changed: 82 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import java.time.Instant
2323
import scala.concurrent.ExecutionContext
2424
import scala.concurrent.Future
2525
import scala.collection.mutable.ArrayBuffer
26+
import scala.scalanative.meta.LinktimeInfo
2627
import scalanative.posix.grp
2728
import scalanative.posix.pwd
2829
import scalanative.posix.pwdOps.*
@@ -61,10 +62,18 @@ private[io] object FileSystemNative extends FileSystemBase:
6162
override def isDirectory(path: IOPath): Boolean = toJavaFile(path).isDirectory
6263

6364
// Returns true if the path is a symbolic link (without following it).
64-
private def isSymlinkPath(path: IOPath): Boolean = Zone {
65-
val buf = stackalloc[Byte](1)
66-
unistd.readlink(toCString(path.path), buf, 1.toUSize) >= 0
67-
}
65+
//
66+
// MSVC's CRT doesn't export POSIX `readlink`, so on Windows this returns `false` and
67+
// the caller treats the path as a regular file. `LinktimeInfo.isWindows` is resolved at
68+
// link time, so the `unistd.readlink` symbol is DCE'd on Windows builds.
69+
private def isSymlinkPath(path: IOPath): Boolean =
70+
if LinktimeInfo.isWindows then
71+
false
72+
else
73+
Zone {
74+
val buf = stackalloc[Byte](1)
75+
unistd.readlink(toCString(path.path), buf, 1.toUSize) >= 0
76+
}
6877

6978
override def info(path: IOPath): FileInfo =
7079
val file = toJavaFile(path)
@@ -373,35 +382,51 @@ private[io] object FileSystemNative extends FileSystemBase:
373382

374383
override def existsAsync(path: IOPath): Future[Boolean] = Future(exists(path))
375384

376-
override def createSymlink(link: IOPath, target: IOPath): Unit = Zone {
377-
val ret = unistd.symlink(toCString(target.path), toCString(link.path))
378-
if ret != 0 then
379-
throw java.io.IOException(s"Failed to create symlink: ${link.path} -> ${target.path}")
380-
}
381-
382-
override def readSymlink(link: IOPath): IOPath = Zone {
383-
// Buffer sized to PATH_MAX (4096). readlink truncates silently if the target
384-
// were longer, so we treat a full buffer as an error rather than returning
385-
// a corrupt path. In practice symlink targets cannot exceed PATH_MAX.
386-
val bufSize = 4096
387-
val buf = stackalloc[Byte](bufSize)
388-
val len = unistd.readlink(toCString(link.path), buf, (bufSize - 1).toUSize)
389-
if len < 0 then
390-
throw java.io.IOException(s"Failed to read symlink: ${link.path}")
391-
if len.toInt == bufSize - 1 then
392-
throw java.io.IOException(s"Symlink target too long: ${link.path}")
393-
buf(len.toInt) = 0.toByte
394-
IOPath.parse(fromCString(buf))
395-
}
385+
override def createSymlink(link: IOPath, target: IOPath): Unit =
386+
if LinktimeInfo.isWindows then
387+
throw UnsupportedOperationException(
388+
"createSymlink is not supported on Scala Native + Windows"
389+
)
390+
else
391+
Zone {
392+
val ret = unistd.symlink(toCString(target.path), toCString(link.path))
393+
if ret != 0 then
394+
throw java.io.IOException(s"Failed to create symlink: ${link.path} -> ${target.path}")
395+
}
396+
397+
override def readSymlink(link: IOPath): IOPath =
398+
if LinktimeInfo.isWindows then
399+
throw UnsupportedOperationException("readSymlink is not supported on Scala Native + Windows")
400+
else
401+
Zone {
402+
// Buffer sized to PATH_MAX (4096). readlink truncates silently if the target
403+
// were longer, so we treat a full buffer as an error rather than returning
404+
// a corrupt path. In practice symlink targets cannot exceed PATH_MAX.
405+
val bufSize = 4096
406+
val buf = stackalloc[Byte](bufSize)
407+
val len = unistd.readlink(toCString(link.path), buf, (bufSize - 1).toUSize)
408+
if len < 0 then
409+
throw java.io.IOException(s"Failed to read symlink: ${link.path}")
410+
if len.toInt == bufSize - 1 then
411+
throw java.io.IOException(s"Symlink target too long: ${link.path}")
412+
buf(len.toInt) = 0.toByte
413+
IOPath.parse(fromCString(buf))
414+
}
396415

397416
override def permissions(path: IOPath): PermSet = statFileInfo(path)
398417
._1
399418
.getOrElse(throw java.io.IOException(s"Failed to stat: ${path.path}"))
400419

401-
override def setPermissions(path: IOPath, permissions: PermSet): Unit = Zone {
402-
if statMod.chmod(toCString(path.path), permissions.bits.toUInt) != 0 then
403-
throw java.io.IOException(s"Failed to chmod: ${path.path}")
404-
}
420+
override def setPermissions(path: IOPath, permissions: PermSet): Unit =
421+
if LinktimeInfo.isWindows then
422+
throw UnsupportedOperationException(
423+
"POSIX permissions are not supported on Scala Native + Windows"
424+
)
425+
else
426+
Zone {
427+
if statMod.chmod(toCString(path.path), permissions.bits.toUInt) != 0 then
428+
throw java.io.IOException(s"Failed to chmod: ${path.path}")
429+
}
405430

406431
override def owner(path: IOPath): String = statFileInfo(path)
407432
._2
@@ -421,31 +446,38 @@ private[io] object FileSystemNative extends FileSystemBase:
421446
private def doStatFileInfo(
422447
path: IOPath,
423448
followLinks: Boolean
424-
): (Option[PermSet], Option[String], Option[String]) = Zone {
425-
val buf = stackalloc[statMod.stat]()
426-
val ret =
427-
if followLinks then
428-
statMod.stat(toCString(path.path), buf)
429-
else
430-
statMod.lstat(toCString(path.path), buf)
431-
if ret != 0 then
449+
): (Option[PermSet], Option[String], Option[String]) =
450+
// POSIX perms/owner/group don't map to Windows (ACL-based). Return `None`s so the
451+
// Windows branch doesn't reference the `scalanative_stat`/`_lstat`/`_getpwuid`/`_getgrgid`
452+
// shims, which are POSIX-only.
453+
if LinktimeInfo.isWindows then
432454
(None, None, None)
433455
else
434-
val perms = Some(PermSet(buf.st_mode.toInt & PermSet.PermissionMask))
435-
val ownerName =
436-
val pwBuf = stackalloc[pwd.passwd]()
437-
if pwd.getpwuid(buf.st_uid, pwBuf) == 0 then
438-
Some(fromCString(pwBuf.pw_name))
439-
else
440-
None
441-
val groupName =
442-
val grBuf = stackalloc[grp.group]()
443-
if grp.getgrgid(buf.st_gid, grBuf) == 0 then
444-
Some(fromCString(grBuf._1)) // _1 is gr_name
456+
Zone {
457+
val buf = stackalloc[statMod.stat]()
458+
val ret =
459+
if followLinks then
460+
statMod.stat(toCString(path.path), buf)
461+
else
462+
statMod.lstat(toCString(path.path), buf)
463+
if ret != 0 then
464+
(None, None, None)
445465
else
446-
None
447-
(perms, ownerName, groupName)
448-
}
466+
val perms = Some(PermSet(buf.st_mode.toInt & PermSet.PermissionMask))
467+
val ownerName =
468+
val pwBuf = stackalloc[pwd.passwd]()
469+
if pwd.getpwuid(buf.st_uid, pwBuf) == 0 then
470+
Some(fromCString(pwBuf.pw_name))
471+
else
472+
None
473+
val groupName =
474+
val grBuf = stackalloc[grp.group]()
475+
if grp.getgrgid(buf.st_gid, grBuf) == 0 then
476+
Some(fromCString(grBuf._1)) // _1 is gr_name
477+
else
478+
None
479+
(perms, ownerName, groupName)
480+
}
449481

450482
end FileSystemNative
451483

0 commit comments

Comments
 (0)