Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions millbun/src/mill/bun/BunVendoredNodeModules.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 14 additions & 3 deletions millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}
Expand Down
19 changes: 19 additions & 0 deletions millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down