feat(infra:artifacts-cas): DefaultMaterializingArtifactWriter — sandbox validation, file write, Blake3 hash
This commit is contained in:
@@ -5,7 +5,9 @@ plugins {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
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:events"))
|
||||||
|
implementation(project(":core:artifacts"))
|
||||||
implementation(project(":core:artifacts-store"))
|
implementation(project(":core:artifacts-store"))
|
||||||
implementation "org.xerial:sqlite-jdbc"
|
implementation "org.xerial:sqlite-jdbc"
|
||||||
implementation "org.bouncycastle:bcprov-jdk18on:1.78.1"
|
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