diff --git a/infrastructure/artifacts-cas/build.gradle b/infrastructure/artifacts-cas/build.gradle index b75ae76a..0518f955 100644 --- a/infrastructure/artifacts-cas/build.gradle +++ b/infrastructure/artifacts-cas/build.gradle @@ -5,7 +5,9 @@ plugins { dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" + implementation "org.slf4j:slf4j-api:2.0.16" implementation(project(":core:events")) + implementation(project(":core:artifacts")) implementation(project(":core:artifacts-store")) implementation "org.xerial:sqlite-jdbc" implementation "org.bouncycastle:bcprov-jdk18on:1.78.1" diff --git a/infrastructure/artifacts-cas/src/main/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriter.kt b/infrastructure/artifacts-cas/src/main/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriter.kt new file mode 100644 index 00000000..634f99c3 --- /dev/null +++ b/infrastructure/artifacts-cas/src/main/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriter.kt @@ -0,0 +1,75 @@ +package com.correx.infrastructure.artifactscas + +import com.correx.core.artifacts.MaterializationResult +import com.correx.core.artifacts.MaterializingArtifactWriter +import com.correx.core.artifacts.kind.FileWrittenArtifact +import com.correx.core.artifacts.kind.FileWrittenPayload +import com.correx.infrastructure.artifactscas.segment.SegmentLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions + +class DefaultMaterializingArtifactWriter : MaterializingArtifactWriter { + + private companion object { + val log = LoggerFactory.getLogger(DefaultMaterializingArtifactWriter::class.java) + const val DEFAULT_MODE = "644" + const val OCTAL_RADIX = 8 + const val POSIX_PERMISSION_BITS = 9 + const val HEX_RADIX = 16 + const val BYTE_MASK = 0xFF + } + + @Suppress("ReturnCount") + override suspend fun materialize(payload: FileWrittenPayload, sandboxRoot: Path): MaterializationResult { + if (payload.path.isBlank()) return MaterializationResult.Failure("path is blank") + val payloadPath = Path.of(payload.path) + if (payloadPath.isAbsolute) return MaterializationResult.Failure("path must be relative: ${payload.path}") + if (payloadPath.any { it.toString() == ".." }) { + return MaterializationResult.Failure("path must not contain '..': ${payload.path}") + } + + val resolvedPath = sandboxRoot.resolve(payloadPath).normalize() + if (!resolvedPath.startsWith(sandboxRoot)) { + return MaterializationResult.Failure("path escapes sandbox: ${payload.path}") + } + + return runCatching { + withContext(Dispatchers.IO) { + resolvedPath.parent?.let { Files.createDirectories(it) } + Files.writeString(resolvedPath, payload.content) + runCatching { + val octal = payload.mode.trimStart('0').ifEmpty { DEFAULT_MODE }.toInt(OCTAL_RADIX) + val perms = PosixFilePermissions.fromString(octalToSymbolic(octal)) + Files.setPosixFilePermissions(resolvedPath, perms) + }.onFailure { + log.warn("Failed to set permissions on {}: {}", resolvedPath, it.message) + } + val bytes = payload.content.toByteArray(Charsets.UTF_8) + val hash = SegmentLayout.blake3(bytes).toHex() + MaterializationResult.Success( + artifact = FileWrittenArtifact( + path = payload.path, + contentHash = hash, + size = bytes.size.toLong(), + mode = payload.mode, + ), + resolvedPath = resolvedPath, + ) + } + }.getOrElse { MaterializationResult.Failure("IO error: ${it.message}") } + } + + private fun octalToSymbolic(octal: Int): String { + val perms = "rwxrwxrwx" + return CharArray(POSIX_PERMISSION_BITS) { i -> + if ((octal shr (POSIX_PERMISSION_BITS - 1 - i)) and 1 == 1) perms[i] else '-' + }.let { String(it) } + } + + private fun ByteArray.toHex(): String = + joinToString("") { it.toInt().and(BYTE_MASK).toString(HEX_RADIX).padStart(2, '0') } +} diff --git a/infrastructure/artifacts-cas/src/test/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriterTest.kt b/infrastructure/artifacts-cas/src/test/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriterTest.kt new file mode 100644 index 00000000..832d8c80 --- /dev/null +++ b/infrastructure/artifacts-cas/src/test/kotlin/com/correx/infrastructure/artifactscas/DefaultMaterializingArtifactWriterTest.kt @@ -0,0 +1,72 @@ +package com.correx.infrastructure.artifactscas + +import com.correx.core.artifacts.MaterializationResult +import com.correx.core.artifacts.kind.FileWrittenPayload +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class DefaultMaterializingArtifactWriterTest { + + private val writer = DefaultMaterializingArtifactWriter() + + @Test + fun `valid payload materializes successfully with correct path, non-empty hash and correct size`( + @TempDir sandbox: Path, + ): Unit = runBlocking { + val content = "hello world" + val payload = FileWrittenPayload(path = "subdir/hello.txt", content = content, mode = "0644") + + val result = writer.materialize(payload, sandbox) + + assertInstanceOf(MaterializationResult.Success::class.java, result) + result as MaterializationResult.Success + assertFalse(result.artifact.contentHash.isBlank(), "contentHash must not be blank") + assertTrue(result.artifact.size > 0, "size must be positive") + assertTrue(result.resolvedPath.startsWith(sandbox), "resolved path must be inside sandbox") + assertNotNull(result.resolvedPath) + } + + @Test + fun `absolute path returns failure`(@TempDir sandbox: Path): Unit = runBlocking { + val payload = FileWrittenPayload(path = "/etc/passwd", content = "x") + + val result = writer.materialize(payload, sandbox) + + assertInstanceOf(MaterializationResult.Failure::class.java, result) + } + + @Test + fun `path with dot-dot returns failure`(@TempDir sandbox: Path): Unit = runBlocking { + val payload = FileWrittenPayload(path = "../escape.txt", content = "x") + + val result = writer.materialize(payload, sandbox) + + assertInstanceOf(MaterializationResult.Failure::class.java, result) + } + + @Test + fun `blank path returns failure`(@TempDir sandbox: Path): Unit = runBlocking { + val payload = FileWrittenPayload(path = " ", content = "x") + + val result = writer.materialize(payload, sandbox) + + assertInstanceOf(MaterializationResult.Failure::class.java, result) + } + + @Test + fun `path that escapes sandbox after normalization returns failure`(@TempDir sandbox: Path): Unit = runBlocking { + // Construct a path like "subdir/../../../../../../tmp/evil" that normalizes outside sandbox + val escaping = "subdir/" + "../".repeat(20) + "evil.txt" + val payload = FileWrittenPayload(path = escaping, content = "x") + + val result = writer.materialize(payload, sandbox) + + assertInstanceOf(MaterializationResult.Failure::class.java, result) + } +}