feat(core:validation): ArtifactPayloadValidator for file_written and process_result; make Validator suspend
This commit is contained in:
@@ -8,4 +8,6 @@ dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:transitions"))
|
||||
implementation(project(":core:artifacts"))
|
||||
implementation(project(":core:artifacts-store"))
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.correx.core.validation.artifact
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.nio.file.Path
|
||||
|
||||
class ArtifactPayloadValidator(
|
||||
private val artifactStore: ArtifactStore,
|
||||
) : Validator {
|
||||
|
||||
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
for ((_, stageConfig) in context.graph.stages) {
|
||||
for (slot in stageConfig.produces) {
|
||||
validateSlot(slot, issues)
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationSection(name = "artifact_payload", issues = issues)
|
||||
}
|
||||
|
||||
private suspend fun validateSlot(
|
||||
slot: TypedArtifactSlot,
|
||||
issues: MutableList<ValidationIssue>,
|
||||
) {
|
||||
val bytes = artifactStore.get(slot.name) ?: return
|
||||
|
||||
val payloadStr = bytes.toString(Charsets.UTF_8)
|
||||
|
||||
when (slot.kind.id) {
|
||||
"file_written" ->
|
||||
validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues)
|
||||
"process_result" ->
|
||||
validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateSlotAsFileWritten(
|
||||
slotName: ArtifactId,
|
||||
kindId: String,
|
||||
payloadStr: String,
|
||||
issues: MutableList<ValidationIssue>,
|
||||
) {
|
||||
val artifact = runCatching {
|
||||
Json.decodeFromString(FileWrittenArtifact.serializer(), payloadStr)
|
||||
}.getOrElse {
|
||||
issues += decodeFailedIssue(slotName, kindId, it.message)
|
||||
return
|
||||
}
|
||||
validateFileWritten(artifact, slotName, issues)
|
||||
}
|
||||
|
||||
private fun validateSlotAsProcessResult(
|
||||
slotName: ArtifactId,
|
||||
kindId: String,
|
||||
payloadStr: String,
|
||||
issues: MutableList<ValidationIssue>,
|
||||
) {
|
||||
val artifact = runCatching {
|
||||
Json.decodeFromString(ProcessResultArtifact.serializer(), payloadStr)
|
||||
}.getOrElse {
|
||||
issues += decodeFailedIssue(slotName, kindId, it.message)
|
||||
return
|
||||
}
|
||||
validateProcessResult(artifact, slotName, issues)
|
||||
}
|
||||
|
||||
private fun validateFileWritten(
|
||||
artifact: FileWrittenArtifact,
|
||||
name: ArtifactId,
|
||||
issues: MutableList<ValidationIssue>,
|
||||
) {
|
||||
if (artifact.path.isBlank()) issues += semanticIssue(name, "path is blank")
|
||||
if (Path.of(artifact.path).isAbsolute) issues += semanticIssue(name, "path must be relative")
|
||||
if (artifact.path.contains("..")) issues += semanticIssue(name, "path contains '..'")
|
||||
if (artifact.contentHash.isBlank()) issues += semanticIssue(name, "contentHash is blank")
|
||||
// content non-empty cannot be checked here: FileWrittenArtifact is the canonical (CAS) form
|
||||
// which contains only the hash, not the raw content bytes.
|
||||
if (!Regex("^0?[0-7]{3,4}$").matches(artifact.mode)) {
|
||||
issues += semanticIssue(name, "mode '${artifact.mode}' is not a valid octal permission string")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateProcessResult(
|
||||
artifact: ProcessResultArtifact,
|
||||
name: ArtifactId,
|
||||
issues: MutableList<ValidationIssue>,
|
||||
) {
|
||||
if (artifact.command.isBlank()) issues += semanticIssue(name, "command is blank")
|
||||
}
|
||||
|
||||
private fun decodeFailedIssue(name: ArtifactId, kindId: String, errorMessage: String?) = ValidationIssue(
|
||||
code = "ARTIFACT_DECODE_FAILED",
|
||||
message = "Artifact '${name.value}' failed to decode as $kindId: $errorMessage",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
|
||||
private fun semanticIssue(name: ArtifactId, message: String) = ValidationIssue(
|
||||
code = "ARTIFACT_SEMANTIC_INVALID",
|
||||
message = "Artifact '${name.value}': $message",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class GraphValidator : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val graph = context.graph
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ class ValidationPipeline(
|
||||
private val approvalTrigger: ApprovalTrigger? = null
|
||||
) {
|
||||
|
||||
fun validate(context: ValidationContext): ValidationOutcome {
|
||||
suspend fun validate(context: ValidationContext): ValidationOutcome {
|
||||
val sections = mutableListOf<ValidationSection>()
|
||||
for (validator in validators) {
|
||||
val section = validator.validate(context)
|
||||
|
||||
@@ -4,5 +4,5 @@ import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
|
||||
fun interface Validator {
|
||||
fun validate(context: ValidationContext): ValidationSection
|
||||
suspend fun validate(context: ValidationContext): ValidationSection
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ class SemanticValidator(
|
||||
private val rules: List<SemanticRule>
|
||||
) : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val issues = rules.flatMap { it.validate(context) }
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class SessionValidator : Validator {
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val state = context.sessionState ?: return ValidationSection(
|
||||
name = "session",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class TransitionValidator(
|
||||
private val ordering: Comparator<TransitionEdge> = TransitionOrdering.comparator
|
||||
) : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val graph = context.graph
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
Reference in New Issue
Block a user