feat(infra:artifacts-cas): DefaultMaterializingArtifactWriter — sandbox validation, file write, Blake3 hash
This commit is contained in:
@@ -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"
|
||||
|
||||
+75
@@ -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') }
|
||||
}
|
||||
+72
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user