first commit
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAIInvocationPromptSize = 8000
|
||||
maxAIInvocationConfigSize = 64 * 1024
|
||||
maxAIInvocationContextRefs = 12
|
||||
)
|
||||
|
||||
func ValidateAIInvocationRequest(request domain.AIInvocationRequest) error {
|
||||
request = domain.CopyAIInvocationRequest(request)
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "requestId", request.RequestID)
|
||||
violations = appendRequired(violations, "purpose", request.Purpose)
|
||||
violations = appendRequired(violations, "prompt", request.Prompt)
|
||||
if request.Purpose != "" && !validAIPurpose(request.Purpose) {
|
||||
violations = append(violations, "purpose is not allowed")
|
||||
}
|
||||
if len([]byte(request.Prompt)) > maxAIInvocationPromptSize {
|
||||
violations = append(violations, "prompt is too large")
|
||||
}
|
||||
if len([]byte(request.CurrentConfig)) > maxAIInvocationConfigSize {
|
||||
violations = append(violations, "currentConfig is too large")
|
||||
}
|
||||
if request.ProviderID != "" && !safeIdentifier(request.ProviderID) {
|
||||
violations = append(violations, "providerId is invalid")
|
||||
}
|
||||
if request.Model != "" && unsafeAIString(request.Model) {
|
||||
violations = append(violations, "model is unsafe")
|
||||
}
|
||||
if len(request.ContextRefs) > maxAIInvocationContextRefs {
|
||||
violations = append(violations, "contextRefs has too many keys")
|
||||
}
|
||||
for key, value := range request.ContextRefs {
|
||||
if strings.TrimSpace(key) == "" || key != strings.TrimSpace(key) {
|
||||
violations = append(violations, "contextRefs key is invalid")
|
||||
}
|
||||
if !validAIContextRef(value) {
|
||||
violations = append(violations, fmt.Sprintf("contextRefs[%s] is invalid", key))
|
||||
}
|
||||
}
|
||||
for _, value := range []fieldString{
|
||||
{field: "requestId", value: request.RequestID},
|
||||
{field: "prompt", value: request.Prompt},
|
||||
{field: "currentConfig", value: request.CurrentConfig},
|
||||
{field: "providerId", value: request.ProviderID},
|
||||
{field: "model", value: request.Model},
|
||||
} {
|
||||
if unsafeAIString(value.value) {
|
||||
violations = append(violations, value.field+" contains unsafe content")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIInvocationResponse(response domain.AIInvocationResponse) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "requestId", response.RequestID)
|
||||
violations = appendRequired(violations, "purpose", response.Purpose)
|
||||
violations = appendRequired(violations, "status", response.Status)
|
||||
if unsafeAIString(response.Recommendation) {
|
||||
violations = append(violations, "recommendation contains unsafe content")
|
||||
}
|
||||
if response.ConfigRecommendation != nil && unsafeAIString(response.ConfigRecommendation.SuggestedConfig) {
|
||||
violations = append(violations, "configRecommendation contains unsafe content")
|
||||
}
|
||||
if response.Error != nil && unsafeAIString(response.Error.Message) {
|
||||
violations = append(violations, "error message contains unsafe content")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validAIContextRef(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || trimmed != value || unsafeAIString(value) {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(value, "server://") || strings.HasPrefix(value, "log://") || strings.HasPrefix(value, "artifact://") || strings.HasPrefix(value, "input://")
|
||||
}
|
||||
|
||||
func safeIdentifier(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || trimmed != value || len([]rune(value)) > 160 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeAIString(value string) bool {
|
||||
return containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "provider base url secret")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const MaxArtifactDownloadBytes = MaxArtifactChunkBytes
|
||||
|
||||
func ValidateArtifactDownloadReferenceRequest(request domain.ArtifactDownloadReferenceRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "artifactId", request.ArtifactID)
|
||||
violations = appendArtifactIDViolations(violations, request.ArtifactID)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactDownloadReference(reference domain.ArtifactDownloadReference) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "artifactId", reference.ArtifactID)
|
||||
violations = appendRequired(violations, "ownerId", reference.OwnerID)
|
||||
violations = appendRequired(violations, "filename", reference.Filename)
|
||||
violations = appendRequired(violations, "contentType", reference.ContentType)
|
||||
violations = appendRequired(violations, "checksum", reference.Checksum)
|
||||
violations = appendRequired(violations, "downloadUrl", reference.DownloadURL)
|
||||
violations = appendRequired(violations, "storageBehavior", reference.StorageBehavior)
|
||||
violations = appendArtifactIDViolations(violations, reference.ArtifactID)
|
||||
if !validArtifactOwnerKind(reference.OwnerKind) {
|
||||
violations = append(violations, "ownerKind is invalid")
|
||||
}
|
||||
if reference.State != domain.ArtifactStateAvailable {
|
||||
violations = append(violations, "state must be available")
|
||||
}
|
||||
if reference.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if reference.Checksum != "" && !validSHA256Checksum(reference.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
if reference.ChunkSizeBytes <= 0 || reference.ChunkSizeBytes > MaxArtifactDownloadBytes {
|
||||
violations = append(violations, fmt.Sprintf("chunkSizeBytes must be between 1 and %d", MaxArtifactDownloadBytes))
|
||||
}
|
||||
if reference.ExpiresAt.IsZero() {
|
||||
violations = append(violations, "expiresAt is required")
|
||||
}
|
||||
for _, value := range []fieldString{
|
||||
{field: "filename", value: reference.Filename},
|
||||
{field: "contentType", value: reference.ContentType},
|
||||
{field: "downloadUrl", value: reference.DownloadURL},
|
||||
{field: "storageBehavior", value: reference.StorageBehavior},
|
||||
} {
|
||||
if unsafeArtifactString(value.value) {
|
||||
violations = append(violations, value.field+" contains unsafe content")
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(reference.DownloadURL, "/api/v1/artifacts/") || !strings.HasSuffix(reference.DownloadURL, "/content") {
|
||||
violations = append(violations, "downloadUrl must be a platform artifact content route")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactContentRequest(request domain.ArtifactContentRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "artifactId", request.ArtifactID)
|
||||
violations = appendArtifactIDViolations(violations, request.ArtifactID)
|
||||
if request.Offset < 0 {
|
||||
violations = append(violations, "offset must not be negative")
|
||||
}
|
||||
if request.Limit < 0 {
|
||||
violations = append(violations, "limit must not be negative")
|
||||
}
|
||||
if request.Limit > MaxArtifactDownloadBytes {
|
||||
violations = append(violations, fmt.Sprintf("limit must not exceed %d", MaxArtifactDownloadBytes))
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactContent(content domain.ArtifactContent) error {
|
||||
content = domain.CopyArtifactContent(content)
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "artifactId", content.ArtifactID)
|
||||
violations = appendRequired(violations, "filename", content.Filename)
|
||||
violations = appendRequired(violations, "contentType", content.ContentType)
|
||||
violations = appendRequired(violations, "checksum", content.Checksum)
|
||||
violations = appendRequired(violations, "contentChecksum", content.ContentChecksum)
|
||||
violations = appendArtifactIDViolations(violations, content.ArtifactID)
|
||||
if content.Offset < 0 {
|
||||
violations = append(violations, "offset must not be negative")
|
||||
}
|
||||
if content.SizeBytes < 0 {
|
||||
violations = append(violations, "sizeBytes must not be negative")
|
||||
}
|
||||
if content.TotalSizeBytes <= 0 {
|
||||
violations = append(violations, "totalSizeBytes must be positive")
|
||||
}
|
||||
if content.SizeBytes > MaxArtifactDownloadBytes {
|
||||
violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactDownloadBytes))
|
||||
}
|
||||
if int64(len(content.Payload)) != content.SizeBytes {
|
||||
violations = append(violations, "payload size must match sizeBytes")
|
||||
}
|
||||
if content.Offset+content.SizeBytes > content.TotalSizeBytes {
|
||||
violations = append(violations, "range exceeds artifact size")
|
||||
}
|
||||
if content.Checksum != "" && !validSHA256Checksum(content.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
if content.ContentChecksum != "" && content.ContentChecksum != BytesChecksum(content.Payload) {
|
||||
violations = append(violations, "contentChecksum does not match payload")
|
||||
}
|
||||
for _, value := range []fieldString{
|
||||
{field: "filename", value: content.Filename},
|
||||
{field: "contentType", value: content.ContentType},
|
||||
{field: "storageBehavior", value: content.StorageBehavior},
|
||||
} {
|
||||
if unsafeArtifactString(value.value) {
|
||||
violations = append(violations, value.field+" contains unsafe content")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func appendArtifactIDViolations(violations []string, artifactID string) []string {
|
||||
trimmed := strings.TrimSpace(artifactID)
|
||||
if trimmed == "" {
|
||||
return violations
|
||||
}
|
||||
if trimmed != artifactID || len([]rune(trimmed)) > 120 || strings.Contains(trimmed, "/") || strings.Contains(trimmed, `\`) || strings.Contains(trimmed, "://") || strings.Contains(trimmed, "..") {
|
||||
violations = append(violations, "artifactId is invalid")
|
||||
}
|
||||
if unsafeArtifactString(trimmed) {
|
||||
violations = append(violations, "artifactId contains unsafe content")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func unsafeArtifactString(value string) bool {
|
||||
return containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "file://")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const MaxArtifactChunkBytes = 1024 * 1024
|
||||
|
||||
func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", open.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", open.SessionToken)
|
||||
violations = appendRequired(violations, "artifactId", open.ArtifactID)
|
||||
violations = appendRequired(violations, "ownerId", open.OwnerID)
|
||||
violations = appendRequired(violations, "checksum", open.Checksum)
|
||||
violations = appendRequired(violations, "idempotencyKey", open.IdempotencyKey)
|
||||
if open.Direction != domain.ArtifactTransferDirectionUpload {
|
||||
violations = append(violations, "direction must be upload")
|
||||
}
|
||||
if !validArtifactOwnerKind(open.OwnerKind) {
|
||||
violations = append(violations, "ownerKind is invalid")
|
||||
}
|
||||
if open.OwnerKind != domain.ArtifactOwnerKindJob && open.OwnerKind != domain.ArtifactOwnerKindServerInstance {
|
||||
violations = append(violations, "ownerKind must be job or server-instance for run uploads")
|
||||
}
|
||||
if open.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if open.ChunkSizeBytes <= 0 {
|
||||
violations = append(violations, "chunkSizeBytes must be positive")
|
||||
}
|
||||
if open.ChunkSizeBytes > MaxArtifactChunkBytes {
|
||||
violations = append(violations, fmt.Sprintf("chunkSizeBytes must not exceed %d", MaxArtifactChunkBytes))
|
||||
}
|
||||
if open.Checksum != "" && !validSHA256Checksum(open.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactChunkUpload(chunk domain.ArtifactChunkUpload) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", chunk.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", chunk.SessionToken)
|
||||
violations = appendRequired(violations, "transferId", chunk.TransferID)
|
||||
violations = appendRequired(violations, "artifactId", chunk.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", chunk.Checksum)
|
||||
if chunk.ChunkIndex < 0 {
|
||||
violations = append(violations, "chunkIndex must not be negative")
|
||||
}
|
||||
if chunk.Offset < 0 {
|
||||
violations = append(violations, "offset must not be negative")
|
||||
}
|
||||
if chunk.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if chunk.SizeBytes > MaxArtifactChunkBytes {
|
||||
violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactChunkBytes))
|
||||
}
|
||||
if len(chunk.Payload) == 0 {
|
||||
violations = append(violations, "payload must not be empty")
|
||||
}
|
||||
if chunk.SizeBytes > 0 && len(chunk.Payload) != chunk.SizeBytes {
|
||||
violations = append(violations, "sizeBytes must match payload size")
|
||||
}
|
||||
if chunk.Checksum != "" {
|
||||
if !validSHA256Checksum(chunk.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
} else if chunk.Checksum != BytesChecksum(chunk.Payload) {
|
||||
violations = append(violations, "checksum does not match payload")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactTransferStatusQuery(query domain.ArtifactTransferStatusQuery) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", query.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", query.SessionToken)
|
||||
violations = appendRequired(violations, "transferId", query.TransferID)
|
||||
violations = appendRequired(violations, "artifactId", query.ArtifactID)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateArtifactTransferComplete(complete domain.ArtifactTransferComplete) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", complete.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", complete.SessionToken)
|
||||
violations = appendRequired(violations, "transferId", complete.TransferID)
|
||||
violations = appendRequired(violations, "artifactId", complete.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", complete.Checksum)
|
||||
if complete.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if complete.Checksum != "" && !validSHA256Checksum(complete.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func BytesChecksum(payload []byte) string {
|
||||
sum := sha256.Sum256(payload)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validSHA256Checksum(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") {
|
||||
return false
|
||||
}
|
||||
hexValue := strings.TrimPrefix(value, "sha256:")
|
||||
if len(hexValue) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(hexValue)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func ValidateRunControlHello(hello domain.RunControlHello) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "registrationToken", hello.RegistrationToken)
|
||||
violations = appendRequired(violations, "runEndpointId", hello.RunEndpointID)
|
||||
violations = appendRequired(violations, "displayName", hello.DisplayName)
|
||||
violations = appendRequired(violations, "version", hello.Version)
|
||||
violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint)
|
||||
if !validRunControlStatus(hello.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
violations = appendCapacityViolations(violations, hello.Capacity)
|
||||
violations = appendCapabilitiesViolations(violations, "capabilityReport.capabilities", hello.CapabilityReport.Capabilities)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunControlHeartbeat(heartbeat domain.RunControlHeartbeat) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", heartbeat.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", heartbeat.SessionToken)
|
||||
violations = appendRequired(violations, "version", heartbeat.Version)
|
||||
violations = appendRequired(violations, "capabilityFingerprint", heartbeat.CapabilityFingerprint)
|
||||
if !validRunControlStatus(heartbeat.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
violations = appendCapacityViolations(violations, heartbeat.Capacity)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string {
|
||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs {
|
||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func appendCapabilitiesViolations(violations []string, field string, capabilities []string) []string {
|
||||
if len(capabilities) == 0 {
|
||||
violations = append(violations, field+" must not be empty")
|
||||
}
|
||||
for i, capability := range capabilities {
|
||||
if strings.TrimSpace(capability) == "" {
|
||||
violations = append(violations, fmt.Sprintf("%s[%d] is required", field, i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validRunControlStatus(status domain.RunEndpointStatus) bool {
|
||||
switch status {
|
||||
case domain.RunEndpointStatusOnline, domain.RunEndpointStatusDegraded, domain.RunEndpointStatusOffline:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const maxJobChannelMessageLength = 256
|
||||
|
||||
func ValidateRunJobClaim(claim domain.RunJobClaim) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", claim.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", claim.SessionToken)
|
||||
violations = appendCapacityViolations(violations, claim.Capacity)
|
||||
for i, capability := range claim.Capabilities {
|
||||
if strings.TrimSpace(capability) == "" {
|
||||
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobAck(ack domain.RunJobAck) error {
|
||||
var violations []string
|
||||
violations = appendLeaseFields(violations, ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt)
|
||||
violations = appendMessageLength(violations, "message", ack.Message)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobProgress(progress domain.RunJobProgress) error {
|
||||
var violations []string
|
||||
violations = appendLeaseFields(violations, progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt)
|
||||
violations = appendProgressViolations(violations, progress.Progress)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobResult(result domain.RunJobResult) error {
|
||||
var violations []string
|
||||
violations = appendLeaseFields(violations, result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt)
|
||||
if !validTerminalJobState(result.State) {
|
||||
violations = append(violations, "state must be succeeded, failed, or cancelled")
|
||||
}
|
||||
violations = appendProgressViolations(violations, result.Progress)
|
||||
violations = appendMessageLength(violations, "message", result.Message)
|
||||
violations = appendMessageLength(violations, "errorCode", result.ErrorCode)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "jobId", request.JobID)
|
||||
violations = appendRequired(violations, "reason", request.Reason)
|
||||
violations = appendMessageLength(violations, "reason", request.Reason)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobCancelPoll(poll domain.RunJobCancelPoll) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", poll.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", poll.SessionToken)
|
||||
if poll.LeaseToken != "" && strings.TrimSpace(poll.JobID) == "" {
|
||||
violations = append(violations, "jobId is required when leaseToken is provided")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobReconcile(reconcile domain.RunJobReconcile) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", reconcile.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", reconcile.SessionToken)
|
||||
seen := map[string]struct{}{}
|
||||
for i, jobID := range reconcile.ActiveJobIDs {
|
||||
jobID = strings.TrimSpace(jobID)
|
||||
if jobID == "" {
|
||||
violations = append(violations, fmt.Sprintf("activeJobIds[%d] is required", i))
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[jobID]; exists {
|
||||
violations = append(violations, fmt.Sprintf("activeJobIds[%d] duplicates %q", i, jobID))
|
||||
}
|
||||
seen[jobID] = struct{}{}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func appendLeaseFields(violations []string, runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) []string {
|
||||
violations = appendRequired(violations, "runEndpointId", runEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", sessionToken)
|
||||
violations = appendRequired(violations, "jobId", jobID)
|
||||
violations = appendRequired(violations, "leaseToken", leaseToken)
|
||||
if attempt <= 0 {
|
||||
violations = append(violations, "attempt must be positive")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func appendProgressViolations(violations []string, progress domain.RunJobProgressReport) []string {
|
||||
if progress.Percent < 0 || progress.Percent > 100 {
|
||||
violations = append(violations, "progress.percent must be between 0 and 100")
|
||||
}
|
||||
violations = appendMessageLength(violations, "progress.message", progress.Message)
|
||||
return violations
|
||||
}
|
||||
|
||||
func appendMessageLength(violations []string, field string, message string) []string {
|
||||
if len(message) > maxJobChannelMessageLength {
|
||||
violations = append(violations, field+" is too long")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validTerminalJobState(state domain.JobState) bool {
|
||||
switch state {
|
||||
case domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxLogBatchEntries = 512
|
||||
MaxLogLineLength = 8192
|
||||
MaxLogQueryLimit = 500
|
||||
)
|
||||
|
||||
func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", batch.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", batch.SessionToken)
|
||||
violations = appendRequired(violations, "logStreamId", batch.LogStreamID)
|
||||
violations = appendRequired(violations, "serverInstanceId", batch.ServerInstanceID)
|
||||
violations = appendRequired(violations, "streamKey", batch.StreamKey)
|
||||
violations = appendRequired(violations, "checksum", batch.Checksum)
|
||||
if !validLogStreamSource(batch.Source) {
|
||||
violations = append(violations, "source is invalid")
|
||||
}
|
||||
if batch.FirstSeq == 0 || batch.LastSeq == 0 {
|
||||
violations = append(violations, "sequence range must be positive")
|
||||
}
|
||||
if batch.FirstSeq > batch.LastSeq {
|
||||
violations = append(violations, "firstSeq must not exceed lastSeq")
|
||||
}
|
||||
if batch.Compression != "" && batch.Compression != "none" {
|
||||
violations = append(violations, "compression is invalid")
|
||||
}
|
||||
if len(batch.Entries) == 0 {
|
||||
violations = append(violations, "entries must not be empty")
|
||||
}
|
||||
if len(batch.Entries) > MaxLogBatchEntries {
|
||||
violations = append(violations, fmt.Sprintf("entries must not exceed %d", MaxLogBatchEntries))
|
||||
}
|
||||
if len(batch.Entries) > 0 {
|
||||
expectedCount := int(batch.LastSeq - batch.FirstSeq + 1)
|
||||
if expectedCount != len(batch.Entries) {
|
||||
violations = append(violations, "sequence range must match entry count")
|
||||
}
|
||||
}
|
||||
for i, entry := range batch.Entries {
|
||||
if entry.Seq != batch.FirstSeq+uint64(i) {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i))
|
||||
}
|
||||
if strings.TrimSpace(entry.Line) == "" {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].line is required", i))
|
||||
}
|
||||
if len(entry.Line) > MaxLogLineLength {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].line is too long", i))
|
||||
}
|
||||
for key := range entry.Fields {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if batch.Checksum != "" {
|
||||
computed, err := LogEntriesChecksum(batch.Entries)
|
||||
if err != nil {
|
||||
violations = append(violations, "checksum cannot be computed")
|
||||
} else if batch.Checksum != computed {
|
||||
violations = append(violations, "checksum does not match entries")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateLogStreamCursorQuery(query domain.LogStreamCursorQuery) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "logStreamId", query.LogStreamID)
|
||||
if query.Limit < 0 {
|
||||
violations = append(violations, "limit must not be negative")
|
||||
}
|
||||
if query.Limit > MaxLogQueryLimit {
|
||||
violations = append(violations, fmt.Sprintf("limit must not exceed %d", MaxLogQueryLimit))
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func LogEntriesChecksum(entries []domain.LogEntry) (string, error) {
|
||||
stable := make([]logEntryChecksumBody, len(entries))
|
||||
for i, entry := range entries {
|
||||
stable[i] = logEntryChecksumBody{
|
||||
Seq: entry.Seq,
|
||||
Timestamp: entry.Timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"),
|
||||
Level: entry.Level,
|
||||
Line: entry.Line,
|
||||
Fields: entry.Fields,
|
||||
Redacted: entry.Redacted,
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(stable)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
type logEntryChecksumBody struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Redacted bool `json:"redacted"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateAIProviderRejectsRawSecret(t *testing.T) {
|
||||
provider := validAIProvider()
|
||||
provider.APIKeyRef = "sk-test-secret"
|
||||
|
||||
err := ValidateAIProvider(provider)
|
||||
if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") {
|
||||
t.Fatalf("expected raw secret rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAIProviderRequiresDefaultModelInModels(t *testing.T) {
|
||||
provider := validAIProvider()
|
||||
provider.DefaultModel = "missing-model"
|
||||
|
||||
err := ValidateAIProvider(provider)
|
||||
if err == nil || !strings.Contains(err.Error(), "defaultModel must be included") {
|
||||
t.Fatalf("expected default model validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistration(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected manifest registration to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Description = "requires direct run socket and raw AI key material"
|
||||
registration.ManifestRef = "file:///etc/plugin.json"
|
||||
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe manifest registration rejection")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, want := range []string{"manifestRef is unsafe", "direct run access", "raw credential or AI/provider key"} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected validation error to contain %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "provider.key.read")
|
||||
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "manifest.capabilities") || !strings.Contains(err.Error(), "permissions") {
|
||||
t.Fatalf("expected unsafe capability and permission rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateServerInstanceDependencies(t *testing.T) {
|
||||
instance := domain.ServerInstance{
|
||||
ID: "server-1",
|
||||
PluginID: "server.scum",
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM #1",
|
||||
State: domain.ServerInstanceStateDraft,
|
||||
ConfigVersion: 1,
|
||||
}
|
||||
plugin := domain.GamePlugin{
|
||||
ID: "server.scum",
|
||||
Version: "1.0.0",
|
||||
RequiredRunCapabilities: []string{"process.start", "logs.read"},
|
||||
Status: domain.GamePluginStatusInstalled,
|
||||
}
|
||||
endpoint := domain.RunEndpoint{
|
||||
ID: "run-local",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
Capabilities: []string{"process.start", "logs.read", "files.read"},
|
||||
}
|
||||
|
||||
if err := ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
t.Fatalf("expected valid dependencies, got %v", err)
|
||||
}
|
||||
|
||||
endpoint.Capabilities = []string{"process.start"}
|
||||
err := ValidateServerInstanceDependencies(instance, plugin, endpoint)
|
||||
if err == nil || !strings.Contains(err.Error(), "logs.read") {
|
||||
t.Fatalf("expected missing capability error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateServerInstanceRejectsDeletedCreateState(t *testing.T) {
|
||||
instance := domain.ServerInstance{
|
||||
ID: "server-1",
|
||||
PluginID: "server.scum",
|
||||
PluginVersion: "1.0.0",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM #1",
|
||||
State: domain.ServerInstanceStateDeleted,
|
||||
}
|
||||
|
||||
err := ValidateServerInstance(instance)
|
||||
if err == nil || !strings.Contains(err.Error(), "state must not be deleted") {
|
||||
t.Fatalf("expected deleted state rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJobBoundsProgress(t *testing.T) {
|
||||
job := domain.Job{
|
||||
ID: "job-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: "process.start",
|
||||
IdempotencyKey: "idem-1",
|
||||
State: domain.JobStateQueued,
|
||||
Progress: domain.JobProgress{Percent: 101},
|
||||
}
|
||||
|
||||
err := ValidateJob(job)
|
||||
if err == nil || !strings.Contains(err.Error(), "progress.percent") {
|
||||
t.Fatalf("expected progress bounds error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArtifactLogAndAudit(t *testing.T) {
|
||||
artifact := domain.Artifact{
|
||||
ID: "artifact-1",
|
||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||
OwnerID: "job-1",
|
||||
SizeBytes: 10,
|
||||
Checksum: "sha256:abc",
|
||||
State: domain.ArtifactStateAvailable,
|
||||
}
|
||||
if err := ValidateArtifact(artifact); err != nil {
|
||||
t.Fatalf("expected artifact to validate, got %v", err)
|
||||
}
|
||||
|
||||
stream := domain.LogStream{
|
||||
ID: "log-1",
|
||||
ServerInstanceID: "server-1",
|
||||
Source: domain.LogStreamSourceFile,
|
||||
StreamKey: "server.log",
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
}
|
||||
if err := ValidateLogStream(stream); err != nil {
|
||||
t.Fatalf("expected log stream to validate, got %v", err)
|
||||
}
|
||||
|
||||
audit := domain.AuditEvent{
|
||||
ID: "audit-1",
|
||||
ActorID: "user-1",
|
||||
Action: "server.create",
|
||||
ResourceKind: "server-instance",
|
||||
ResourceID: "server-1",
|
||||
Result: domain.AuditResultSuccess,
|
||||
Summary: "created server instance",
|
||||
}
|
||||
if err := ValidateAuditEvent(audit); err != nil {
|
||||
t.Fatalf("expected audit event to validate, got %v", err)
|
||||
}
|
||||
|
||||
audit.Summary = "bearer raw-secret"
|
||||
if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") {
|
||||
t.Fatalf("expected audit redaction error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validAIProvider() domain.AIProvider {
|
||||
return domain.AIProvider{
|
||||
ID: "ai.openai",
|
||||
Name: "OpenAI",
|
||||
Kind: domain.AIProviderKindOpenAI,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKeyRef: "secret://providers/openai",
|
||||
Models: []string{"gpt-4.1", "gpt-4.1-mini"},
|
||||
DefaultModel: "gpt-4.1",
|
||||
RelayMode: domain.AIRelayModeDirect,
|
||||
TimeoutMS: 30000,
|
||||
Status: domain.AIProviderStatusActive,
|
||||
RedactionPolicy: "default",
|
||||
}
|
||||
}
|
||||
|
||||
func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration {
|
||||
return domain.GamePluginManifestRegistration{
|
||||
ManifestRef: "artifact://manifests/game.example/0.1.0",
|
||||
Manifest: domain.GamePluginManifest{
|
||||
ID: "game.example",
|
||||
Name: "Example Server",
|
||||
Description: "Development plugin",
|
||||
Version: "0.1.0",
|
||||
Kind: "game-plugin",
|
||||
Tags: []string{"example", "development"},
|
||||
Server: domain.GamePluginManifestServer{
|
||||
Type: "example",
|
||||
DisplayName: "Example Server",
|
||||
SupportedOS: []string{"linux", "darwin"},
|
||||
CreateFormSchema: "schemas/create-form.schema.json",
|
||||
},
|
||||
Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"},
|
||||
Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"},
|
||||
Actions: domain.PluginLifecycleActions{
|
||||
Install: "actions/install.json",
|
||||
Start: "actions/start.json",
|
||||
Stop: "actions/stop.json",
|
||||
Restart: "actions/restart.json",
|
||||
},
|
||||
Pages: []domain.GamePluginPage{
|
||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Platform Validation Rules
|
||||
|
||||
- API handlers must use named DTOs from `platform/dto`.
|
||||
- Platform services must not accept raw plugin-provided host paths.
|
||||
- AI provider secrets must be stored by reference and redacted from logs, audit, and plugin bridge responses.
|
||||
- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references.
|
||||
- Server instance creation must validate plugin installation state and run endpoint capability compatibility.
|
||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||
@@ -0,0 +1,50 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const maxLifecycleIdempotencyKeyLength = 160
|
||||
|
||||
func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", create.ID)
|
||||
violations = appendRequired(violations, "pluginId", create.PluginID)
|
||||
violations = appendRequired(violations, "runEndpointId", create.RunEndpointID)
|
||||
violations = appendRequired(violations, "name", create.Name)
|
||||
violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", command.ServerInstanceID)
|
||||
if command.ExpectedConfigVersion <= 0 {
|
||||
violations = append(violations, "expectedConfigVersion must be positive")
|
||||
}
|
||||
violations = appendLifecycleIdempotencyViolations(violations, command.IdempotencyKey)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop:
|
||||
return nil
|
||||
default:
|
||||
return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}}
|
||||
}
|
||||
}
|
||||
|
||||
func appendLifecycleIdempotencyViolations(violations []string, key string) []string {
|
||||
violations = appendRequired(violations, "idempotencyKey", key)
|
||||
if len(key) > maxLifecycleIdempotencyKeyLength {
|
||||
violations = append(violations, "idempotencyKey is too long")
|
||||
}
|
||||
if strings.TrimSpace(key) != key {
|
||||
violations = append(violations, "idempotencyKey must not have surrounding whitespace")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
Reference in New Issue
Block a user