diff --git a/millbun/src/mill/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala index 623d487..23050c0 100644 --- a/millbun/src/mill/bun/BunVendoredNodeModules.scala +++ b/millbun/src/mill/bun/BunVendoredNodeModules.scala @@ -44,9 +44,19 @@ object BunVendoredNodeModules: FileVisitResult.CONTINUE ) - /** Merge vendored node_modules from a classpath entry into a destination. */ + /** Merge vendored node_modules from a classpath entry into a destination. + * + * Refuses a symlinked destination: in workspace mode `node_modules` is a link into the shared + * workspace install's `Task.dest`, and merging through it would silently mutate another task's + * output — shared by every other member of the workspace. + */ def mergeFromClasspathEntry(entry: os.Path, destNodeModules: os.Path): Boolean = - if os.isDir(entry) then mergeFromDir(entry, destNodeModules, entry.toString) + if os.isLink(destNodeModules) then + throw new RuntimeException( + s"Refusing to merge vendored Bun runtime into $destNodeModules: it is a symlink to " + + s"${os.readLink.absolute(destNodeModules)}, which belongs to another task." + ) + else if os.isDir(entry) then mergeFromDir(entry, destNodeModules, entry.toString) else if os.exists(entry) && entry.ext == "jar" then mergeFromJar(entry, destNodeModules) else false diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 1b86361..fbb8800 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -254,8 +254,21 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac transitiveUnmanagedDeps().nonEmpty || bunPackageJsonExtras().value.nonEmpty + val ownResourceRoots = resources().map(_.path).toSet + val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) + bunWorkspaceInstall() match case Some(workspaceInstall) => + // Vendored trees must not be merged here: node_modules is a link into the workspace + // install's dest, so merging would mutate a directory shared by every workspace member. + val vendored = vendoredEntries.filter(BunVendoredNodeModules.hasVendoredNodeModules) + if vendored.nonEmpty then + Task.fail( + s"Bun workspace members cannot consume vendored runtime dependencies: " + + s"${vendored.map(_.last).mkString(", ")}. Depend on the manifest-only artifact, or " + + "install this module outside the workspace." + ) + val installed = workspaceInstall.path if os.exists(installed / "node_modules") then os.symlink(dest / "node_modules", installed / "node_modules") @@ -281,9 +294,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac env = bunEnv() ) - val ownResourceRoots = resources().map(_.path).toSet - val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) - mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") + mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") PathRef(dest) } diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index 54caa89..ac34238 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -100,6 +100,25 @@ object BunVendoredNodeModulesTests extends TestSuite { assert(err.getMessage.contains("Vendored Bun bundle conflict")) } + + test("merging through a symlinked destination is refused") { + // In workspace mode node_modules links into the shared install's Task.dest. Merging + // through it would silently mutate a directory every workspace member depends on. + val source = os.temp.dir() + writeVendoredPackage(source, "react", "19.1.1") + + val shared = os.temp.dir() / "shared-node-modules" + os.makeDir.all(shared) + val member = os.temp.dir() + os.symlink(member / "node_modules", shared) + + val err = intercept[RuntimeException] { + BunVendoredNodeModules.mergeFromClasspathEntry(source, member / "node_modules") + } + assert(err.getMessage.contains("belongs to another task")) + // The critical assertion: nothing leaked into the shared directory. + assert(os.list(shared).isEmpty) + } } private def writeVendoredPackage(root: os.Path, name: String, version: String): Unit = {