From bd914f266ad95c34c663d9b397ac4067b9b5219d Mon Sep 17 00:00:00 2001 From: Jen Basch Date: Wed, 25 Mar 2026 11:50:20 -0700 Subject: [PATCH] Prevent `--multiple-file-output-path` writes from following symlinks outside the target directory (#1467) --- .../main/kotlin/org/pkl/cli/CliEvaluator.kt | 21 +++++++++++-- .../kotlin/org/pkl/cli/CliEvaluatorTest.kt | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt index 34496612..d1f26997 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt @@ -240,8 +240,7 @@ constructor( for ((pathSpec, fileOutput) in output) { checkPathSpec(pathSpec) - val resolvedPath = realOutputDir.resolve(pathSpec).normalize() - val realPath = if (resolvedPath.exists()) resolvedPath.toRealPath() else resolvedPath + val (realPath, resolvedPath) = realOutputDir.resolveRealPath(Path.of(pathSpec)) if (!realPath.startsWith(realOutputDir)) { throw CliException( "Output file conflict: `output.files` entry `\"$pathSpec\"` in module `$moduleUri` resolves to file path `$realPath`, which is outside output directory `$realOutputDir`." @@ -269,4 +268,22 @@ constructor( } } } + + /** + * Resolves [rel] against this Path name-by-name. At each step, the real path is resolved if the + * file exists. The normalized real path and normalized resolved path are returned. This has a + * similar effect to `this.resolve(rel).toRealPath().normalize()`, but the real paths account for + * symlinks in the middle of the relative path so the full path need not exist. + */ + private fun Path.resolveRealPath(rel: Path): Pair { + assert(!rel.isAbsolute) + var resolved = this + var real = this + for (name in rel) { + resolved = resolved.resolve(name) + real = real.resolve(name) + if (real.exists()) real = real.toRealPath() + } + return real.normalize() to resolved.normalize() + } } diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt index 2cf8416e..4001d77e 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt @@ -931,6 +931,37 @@ result = someLib.x .hasMessageContaining("which is outside output directory") } + @Test + @DisabledOnOs(OS.WINDOWS) + fun `multiple file output throws if files are written outside the base path via symlink`() { + val output = tempDir.resolve(".output").createDirectories() + val outside = tempDir.resolve("outside").createDirectories() + output.resolve("outside").createSymbolicLinkPointingTo(outside) + + val moduleUri = + writePklFile( + "test.pkl", + """ + output { + files { + ["outside/foo.txt"] { + text = "bar" + } + } + } + """ + .trimIndent(), + ) + val options = + CliEvaluatorOptions( + CliBaseOptions(sourceModules = listOf(moduleUri), workingDir = tempDir), + multipleFileOutputPath = ".output", + ) + assertThatCode { evalToConsole(options) } + .hasMessageStartingWith("Output file conflict:") + .hasMessageContaining("which is outside output directory") + } + @Test fun `multiple file output throws if file path is a directory`() { tempDir.resolve(".output/myDir").createDirectories()