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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,6 +49,7 @@ internal class GeneratedFileWriter(
javaFiles: List<JavaFile>,
kotlinFiles: List<FileSpec>,
outputDirectory: Path,
isCaseInsensitiveDirectory: (Path) -> Boolean = this::probeCaseInsensitivity,
) {
// Sequential writes through 8.7.0 left the last source at an exact destination.
val files =
Expand All @@ -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
Expand Down Expand Up @@ -143,20 +145,89 @@ internal class GeneratedFileWriter(
}
}

private fun validateUniqueDestinations(files: List<GeneratedFile>) {
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<GeneratedFile>,
outputDirectory: Path,
isCaseInsensitiveDirectory: (Path) -> Boolean,
) {
// A case-sensitive parent keeps its children distinct even when a child directory is case-insensitive.
val insensitiveByDirectory = mutableMapOf<Path, Boolean>()
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<Path, Boolean>,
): 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Path>()

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<Path>()
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<Path>()

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(
Expand Down Expand Up @@ -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 =
"""
Expand Down
Loading