diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/GeneratedFileWriter.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/GeneratedFileWriter.kt index d9a0205d..1f3fc097 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/GeneratedFileWriter.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/GeneratedFileWriter.kt @@ -22,6 +22,7 @@ import com.palantir.javapoet.JavaFile import com.squareup.kotlinpoet.FileSpec import java.io.IOException import java.nio.file.Files +import java.nio.file.NoSuchFileException import java.nio.file.Path import java.util.Locale import java.util.concurrent.Callable @@ -48,6 +49,7 @@ internal class GeneratedFileWriter( javaFiles: List, kotlinFiles: List, outputDirectory: Path, + isCaseInsensitiveDirectory: (Path) -> Boolean = this::probeCaseInsensitivity, ) { // Sequential writes through 8.7.0 left the last source at an exact destination. val files = @@ -67,7 +69,7 @@ internal class GeneratedFileWriter( .values .toList() - validateUniqueDestinations(files) + validateUniqueDestinations(files, outputDirectory, isCaseInsensitiveDirectory) if (parallelism == 1 || files.size < 2) { files.forEach { write(it, outputDirectory) } return @@ -143,20 +145,89 @@ internal class GeneratedFileWriter( } } - private fun validateUniqueDestinations(files: List) { - val duplicate = - files - .groupBy { - it.relativePath - .normalize() - .toString() - .lowercase(Locale.ROOT) - }.entries - .firstOrNull { it.value.size > 1 } - ?.value - ?.first() - ?.relativePath - require(duplicate == null) { "Multiple generated sources target the same path: $duplicate" } + private fun validateUniqueDestinations( + files: List, + outputDirectory: Path, + isCaseInsensitiveDirectory: (Path) -> Boolean, + ) { + // A case-sensitive parent keeps its children distinct even when a child directory is case-insensitive. + val insensitiveByDirectory = mutableMapOf() + files + .groupBy { + it.relativePath + .normalize() + .toString() + .lowercase(Locale.ROOT) + }.values + .filter { it.size > 1 } + .forEach { collision -> + val paths = collision.map { it.relativePath.normalize() } + val duplicate = + paths.indices.any { first -> + (first + 1 until paths.size).any { second -> + destinationsCollide( + paths[first], + paths[second], + outputDirectory, + isCaseInsensitiveDirectory, + insensitiveByDirectory, + ) + } + } + if (duplicate) { + throw IllegalArgumentException( + "Multiple generated sources target the same path: ${collision.first().relativePath}", + ) + } + } + } + + private fun destinationsCollide( + first: Path, + second: Path, + outputDirectory: Path, + isCaseInsensitiveDirectory: (Path) -> Boolean, + insensitiveByDirectory: MutableMap, + ): Boolean { + var directory = outputDirectory.normalize() + for (index in 0 until first.nameCount) { + val component = first.getName(index) + if (component != second.getName(index)) { + val insensitive = + insensitiveByDirectory.getOrPut(directory) { + try { + isCaseInsensitiveDirectory(directory) + } catch (exception: IOException) { + throw CodeGenFileWriteException(directory, exception) + } + } + if (!insensitive) return false + } + directory = directory.resolve(component) + } + return true + } + + /** + * Probes the nearest existing ancestor of [directory], so the probe creates no directories and leaves none behind. + * The probe file is deleted before returning. + */ + private fun probeCaseInsensitivity(directory: Path): Boolean { + var existing = directory.toAbsolutePath() + while (!Files.isDirectory(existing)) { + existing = existing.parent ?: break + } + val probe = Files.createTempFile(existing, ".dgs-case-probe-", ".tmp") + try { + val uppercaseProbe = probe.resolveSibling(probe.fileName.toString().uppercase(Locale.ROOT)) + return try { + Files.isSameFile(probe, uppercaseProbe) + } catch (_: NoSuchFileException) { + false + } + } finally { + Files.deleteIfExists(probe) + } } private fun awaitTermination(executor: ExecutorService) { diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ParallelFileWritingTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ParallelFileWritingTest.kt index 884d339c..cd160562 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ParallelFileWritingTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ParallelFileWritingTest.kt @@ -31,8 +31,11 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import java.nio.file.AccessDeniedException import java.nio.file.Files +import java.nio.file.NoSuchFileException import java.nio.file.Path +import java.util.Locale import java.util.concurrent.AbstractExecutorService import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -190,15 +193,177 @@ class ParallelFileWritingTest { } @Test - fun `case-only duplicate destinations fail before writing`() { + fun `case-only duplicate destinations follow the output filesystem`() { + val output = temporaryDirectory.resolve("case-collision-output") + val caseSensitive = isCaseSensitiveFileSystem(output) + + if (caseSensitive) { + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), + kotlinFiles = emptyList(), + outputDirectory = output, + ) + + val generatedNames = + Files.list(output.resolve("example")).use { paths -> + paths.map { it.fileName.toString() }.toList() + } + assertThat(generatedNames).containsExactlyInAnyOrder("Duplicate.java", "duplicate.java") + } else { + assertThatThrownBy { + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), + kotlinFiles = emptyList(), + outputDirectory = output, + ) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Multiple generated sources target the same path: example/Duplicate.java") + } + + if (Files.exists(output)) { + assertThat(Files.list(output).use { paths -> paths.filter(Files::isRegularFile).count() }).isZero() + } + } + + @Test + fun `case-only duplicates are rejected when the probe reports a case-insensitive directory`() { + val output = temporaryDirectory.resolve("injected-insensitive") + assertThatThrownBy { GeneratedFileWriter(4).write( javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), kotlinFiles = emptyList(), - outputDirectory = temporaryDirectory, + outputDirectory = output, + isCaseInsensitiveDirectory = { true }, + ) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Multiple generated sources target the same path: example/Duplicate.java") + + assertThat(output).doesNotExist() + } + + @Test + fun `case-only duplicates are written when the probe reports a case-sensitive directory`() { + val output = temporaryDirectory.resolve("injected-sensitive") + + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), + kotlinFiles = emptyList(), + outputDirectory = output, + isCaseInsensitiveDirectory = { false }, + ) + + assertThat(Files.exists(output.resolve("example/Duplicate.java"))).isTrue() + assertThat(Files.exists(output.resolve("example/duplicate.java"))).isTrue() + } + + @Test + fun `the probe is not consulted without a case collision`() { + val probed = mutableListOf() + + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("First"), javaFile("Second")), + kotlinFiles = emptyList(), + outputDirectory = temporaryDirectory.resolve("injected-no-collision"), + isCaseInsensitiveDirectory = { directory -> probed.add(directory).let { true } }, + ) + + assertThat(probed).isEmpty() + } + + @Test + fun `each destination directory is probed once and a nested insensitive directory is rejected`() { + val output = temporaryDirectory.resolve("injected-nested") + val nested = output.resolve("nested").normalize() + val probed = mutableListOf() + val writer = GeneratedFileWriter(4) + val probe = { directory: Path -> + probed.add(directory) + directory == nested + } + + // Case-sensitive root: the collision in the root directory is written. + writer.write( + javaFiles = listOf(javaFile("Duplicate", packageName = ""), javaFile("duplicate", packageName = "")), + kotlinFiles = emptyList(), + outputDirectory = output, + isCaseInsensitiveDirectory = probe, + ) + assertThat(probed).containsExactly(output.normalize()) + + probed.clear() + assertThatThrownBy { + writer.write( + javaFiles = + listOf( + javaFile("Duplicate", packageName = ""), + javaFile("duplicate", packageName = ""), + javaFile("Nested", packageName = "nested"), + javaFile("nested", packageName = "nested"), + ), + kotlinFiles = emptyList(), + outputDirectory = output, + isCaseInsensitiveDirectory = probe, ) }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("example/Duplicate.java") + .hasMessage("Multiple generated sources target the same path: nested/Nested.java") + assertThat(probed).containsExactlyInAnyOrder(output.normalize(), nested) + } + + @Test + fun `case-sensitive parent keeps case-variant directories distinct`() { + val output = temporaryDirectory.resolve("case-variant-directories") + val probed = mutableListOf() + + GeneratedFileWriter(1).write( + javaFiles = listOf(javaFile("A", packageName = "Foo"), javaFile("a", packageName = "foo")), + kotlinFiles = emptyList(), + outputDirectory = output, + isCaseInsensitiveDirectory = { directory -> + probed.add(directory) + directory != output + }, + ) + + assertThat(probed).containsExactly(output) + } + + @Test + fun `probe failures are reported as write failures`() { + assertThatThrownBy { + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), + kotlinFiles = emptyList(), + outputDirectory = temporaryDirectory.resolve("injected-failure"), + isCaseInsensitiveDirectory = { throw AccessDeniedException("probe") }, + ) + }.isInstanceOf(CodeGenFileWriteException::class.java) + .hasCauseInstanceOf(AccessDeniedException::class.java) + } + + @Test + fun `the real probe leaves no directories behind when it rejects a collision`() { + val root = temporaryDirectory.resolve("real-probe") + Files.createDirectories(root) + val output = root.resolve("deep/nested") + + val failure = + runCatching { + GeneratedFileWriter(4).write( + javaFiles = listOf(javaFile("Duplicate"), javaFile("duplicate")), + kotlinFiles = emptyList(), + outputDirectory = output, + ) + }.exceptionOrNull() + + // Only a case-insensitive host rejects the pair; a case-sensitive host writes both files. + if (failure != null) { + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + assertThat(root.resolve("deep")).doesNotExist() + } + Files.list(root).use { paths -> + assertThat(paths.filter { it.fileName.toString().startsWith(".dgs-case-probe-") }.count()).isZero() + } } private fun generate( @@ -279,6 +444,21 @@ class ParallelFileWritingTest { ): Boolean = delegate.awaitTermination(timeout, unit) } + private fun isCaseSensitiveFileSystem(directory: Path): Boolean { + Files.createDirectories(directory) + val probe = Files.createTempFile(directory, ".case-probe-", ".tmp") + try { + val uppercaseProbe = probe.resolveSibling(probe.fileName.toString().uppercase(Locale.ROOT)) + return try { + !Files.isSameFile(probe, uppercaseProbe) + } catch (_: NoSuchFileException) { + true + } + } finally { + Files.deleteIfExists(probe) + } + } + companion object { private val SCHEMA = """