From fa9af002a1fcf3440c310d6768fa86a870c9c052 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 18 May 2026 21:39:13 +0400 Subject: [PATCH] feat(core:validation): ArtifactPayloadValidator for file_written and process_result; make Validator suspend --- core/validation/build.gradle | 2 + .../artifact/ArtifactPayloadValidator.kt | 113 ++++++++++++++++++ .../core/validation/graph/GraphValidator.kt | 2 +- .../validation/pipeline/ValidationPipeline.kt | 2 +- .../core/validation/pipeline/Validator.kt | 2 +- .../validation/semantic/SemanticValidator.kt | 2 +- .../validation/session/SessionValidator.kt | 2 +- .../transition/TransitionValidator.kt | 2 +- 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt diff --git a/core/validation/build.gradle b/core/validation/build.gradle index e26fe44b..f1230937 100644 --- a/core/validation/build.gradle +++ b/core/validation/build.gradle @@ -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")) } \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt new file mode 100644 index 00000000..db47d8ef --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt @@ -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() + + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) +} diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt index 22c12e8d..7442167e 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt @@ -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() diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt index 5d8ab8cb..5dfc3fbf 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt @@ -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() for (validator in validators) { val section = validator.validate(context) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt index d3dbd841..0711ccbd 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt @@ -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 } \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt index abb8779a..c50b3912 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt @@ -8,7 +8,7 @@ class SemanticValidator( private val rules: List ) : Validator { - override fun validate(context: ValidationContext): ValidationSection { + override suspend fun validate(context: ValidationContext): ValidationSection { val issues = rules.flatMap { it.validate(context) } diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt index cabeeb62..590fe02c 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt @@ -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", diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt index e2330051..bf4bc412 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt @@ -13,7 +13,7 @@ class TransitionValidator( private val ordering: Comparator = TransitionOrdering.comparator ) : Validator { - override fun validate(context: ValidationContext): ValidationSection { + override suspend fun validate(context: ValidationContext): ValidationSection { val graph = context.graph val issues = mutableListOf()