Freeze SCUM log-source tailing run contract

This commit is contained in:
npc0-hue
2026-08-13 14:37:16 +08:00
parent 41c109128d
commit 4f55c71de5
18 changed files with 627 additions and 6 deletions
+5
View File
@@ -71,6 +71,11 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
violations = append(violations, "executionResult.guardedMutation: "+err.Error())
}
}
if result.ExecutionResult.ParsedLogBatch != nil {
if err := ValidateSCUMParsedLogBatchResult(*result.ExecutionResult.ParsedLogBatch); err != nil {
violations = append(violations, "executionResult.parsedLogBatch: "+err.Error())
}
}
return finish(violations)
}
+5
View File
@@ -1596,6 +1596,11 @@ func ValidateJob(job domain.Job) error {
violations = append(violations, "executionResult.guardedMutation: "+err.Error())
}
}
if job.ExecutionResult.ParsedLogBatch != nil {
if err := ValidateSCUMParsedLogBatchResult(*job.ExecutionResult.ParsedLogBatch); err != nil {
violations = append(violations, "executionResult.parsedLogBatch: "+err.Error())
}
}
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
if job.ServerInstanceID == "" {
violations = append(violations, "serverInstanceId is required for scoped file jobs")
+158
View File
@@ -3,6 +3,7 @@ package validator
import (
"encoding/json"
"fmt"
"net"
"regexp"
"strings"
@@ -26,6 +27,10 @@ const (
maxSCUMRCONConfirmRecords = 128
maxSCUMMutationPayloadBytes = 4096
maxSCUMMutationReadbackBytes = 64 * 1024
maxSCUMParsedLogEvents = 1024
maxSCUMParsedLogPayloadBytes = 64 * 1024
maxSCUMParsedLogLineBytes = 64 * 1024
maxSCUMParsedLogResultBytes = 1024 * 1024
)
var scumHashPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$|^[a-fA-F0-9]{16,128}$`)
@@ -342,6 +347,74 @@ func ValidateSCUMGuardedMutationResult(result domain.SCUMGuardedMutationResult)
return finish(violations)
}
func ValidateSCUMParsedLogBatchResult(result domain.SCUMParsedLogBatchResult) error {
var violations []string
violations = appendRequired(violations, "requestId", result.RequestID)
violations = appendRequired(violations, "jobId", result.JobID)
violations = append(violations, validateSCUMBindingIdentity("binding", result.Binding)...)
if !validSCUMTerminalResultStatus(result.Status) {
violations = append(violations, "status is invalid")
}
for _, item := range []struct{ name, value string }{{"sourceKey", result.SourceKey}, {"streamKey", result.StreamKey}, {"parserKey", result.ParserKey}, {"parserVersion", result.ParserVersion}, {"adapterVersion", result.AdapterVersion}} {
violations = append(violations, validateSCUMTemplateKey(item.name, item.value)...)
if containsSCUMProtectedMaterial(item.value) || containsSCUMNetworkMaterial(item.value) {
violations = append(violations, item.name+" contains protected material")
}
}
for _, item := range []struct{ name, value string }{{"assetDigest", result.AssetDigest}, {"parserDigest", result.ParserDigest}, {"resultDigest", result.ResultDigest}} {
if !validSCUMDigest(item.value) {
violations = append(violations, item.name+" must be sha256 digest")
}
}
violations = append(violations, validateSCUMParsedLogCursor("firstCursor", result.FirstCursor)...)
violations = append(violations, validateSCUMParsedLogCursor("lastCursor", result.LastCursor)...)
if !validSCUMLogTailState(result.TailState) {
violations = append(violations, "tailState is invalid")
}
if result.EventCount != len(result.Events) {
violations = append(violations, "eventCount must match returned events")
}
violations = append(violations, validateSCUMParsedLogBatchBounds("limits", result.Limits)...)
if len(result.Events) > result.Limits.MaxEvents && result.Limits.MaxEvents > 0 {
violations = append(violations, "events exceeds declared limit")
}
if len(result.SafeSummary) > 320 || containsSCUMProtectedMaterial(result.SafeSummary) || containsSCUMNetworkMaterial(result.SafeSummary) || containsSCUMRawXML(result.SafeSummary) {
violations = append(violations, "safeSummary is unsafe")
}
violations = append(violations, validateSCUMSafeError("safeError", result.SafeError)...)
if containsSCUMNetworkMaterial(result.SafeError.Message) || containsSCUMRawXML(result.SafeError.Message) {
violations = append(violations, "safeError.message is unsafe")
}
seenLogical := map[string]struct{}{}
seenTransport := map[string]struct{}{}
for i, event := range result.Events {
field := fmt.Sprintf("events[%d]", i)
violations = append(violations, validateSCUMParsedLogEvent(field, event, result)...)
if event.LogicalEventDigest != "" {
if _, exists := seenLogical[event.LogicalEventDigest]; exists {
violations = append(violations, field+".logicalEventDigest is duplicated")
}
seenLogical[event.LogicalEventDigest] = struct{}{}
}
transport := fmt.Sprintf("%s|%s|%d", event.Cursor.SourceIdentityDigest, event.Cursor.StreamGeneration, event.Cursor.Sequence)
if _, exists := seenTransport[transport]; exists {
violations = append(violations, field+".cursor is duplicated")
}
seenTransport[transport] = struct{}{}
}
if result.EventCount > 0 && result.LastCursor.Sequence < result.FirstCursor.Sequence {
violations = append(violations, "lastCursor.sequence must not be before firstCursor.sequence")
}
if result.Status == domain.SCUMTerminalResultSucceeded {
if result.SafeError.Code != "" && result.SafeError.Code != domain.SCUMSafeErrorNone {
violations = append(violations, "succeeded result must not carry an error code")
}
} else if result.SafeError.Code == "" || result.SafeError.Code == domain.SCUMSafeErrorNone {
violations = append(violations, "non-succeeded result requires a safe error code")
}
return finish(violations)
}
func scumGuardedMutationRequestDigests(request domain.SCUMGuardedMutationRequest) []struct{ name, value string } {
return []struct{ name, value string }{{"assetDigest", request.AssetDigest}, {"targetIdentityDigest", request.TargetIdentityDigest}, {"expectedRowDigest", request.ExpectedRowDigest}, {"expectedValueDigest", request.ExpectedValueDigest}, {"expectedXmlDigest", request.ExpectedXMLDigest}, {"patchDigest", request.PatchDigest}, {"backupEvidenceDigest", request.BackupEvidenceDigest}, {"offlineEvidenceDigest", request.OfflineEvidenceDigest}, {"dangerConfirmationDigest", request.DangerConfirmationDigest}, {"readbackExpectationDigest", request.ReadbackExpectationDigest}}
}
@@ -567,6 +640,62 @@ func validateSCUMGuardedMutationBounds(prefix string, value domain.SCUMGuardedMu
return violations
}
func validateSCUMParsedLogBatchBounds(prefix string, value domain.SCUMParsedLogBatchBounds) []string {
var violations []string
if value.MaxEvents < 0 || value.MaxEvents > maxSCUMParsedLogEvents {
violations = append(violations, prefix+".maxEvents is out of bounds")
}
if value.MaxPayloadBytes < 1 || value.MaxPayloadBytes > maxSCUMParsedLogPayloadBytes {
violations = append(violations, prefix+".maxPayloadBytes is out of bounds")
}
if value.MaxLineBytes < 1 || value.MaxLineBytes > maxSCUMParsedLogLineBytes {
violations = append(violations, prefix+".maxLineBytes is out of bounds")
}
if value.MaxResultBytes < 1 || value.MaxResultBytes > maxSCUMParsedLogResultBytes {
violations = append(violations, prefix+".maxResultBytes is out of bounds")
}
return violations
}
func validateSCUMParsedLogCursor(prefix string, value domain.SCUMParsedLogCursor) []string {
var violations []string
if !validSCUMFingerprint(value.SourceIdentityDigest) || containsSCUMProtectedMaterial(value.SourceIdentityDigest) || containsSCUMNetworkMaterial(value.SourceIdentityDigest) {
violations = append(violations, prefix+".sourceIdentityDigest must be a redacted fingerprint")
}
if !validSCUMFingerprint(value.StreamGeneration) || containsSCUMProtectedMaterial(value.StreamGeneration) || containsSCUMNetworkMaterial(value.StreamGeneration) {
violations = append(violations, prefix+".streamGeneration must be a redacted fingerprint")
}
return violations
}
func validateSCUMParsedLogEvent(prefix string, event domain.SCUMParsedLogEvent, result domain.SCUMParsedLogBatchResult) []string {
var violations []string
violations = append(violations, validateSCUMTemplateKey(prefix+".eventType", event.EventType)...)
violations = append(violations, validateSCUMParsedLogCursor(prefix+".cursor", event.Cursor)...)
for _, item := range []struct{ name, value string }{{"logicalEventDigest", event.LogicalEventDigest}, {"eventDigest", event.EventDigest}, {"payloadDigest", event.PayloadDigest}} {
if !validSCUMDigest(item.value) {
violations = append(violations, prefix+"."+item.name+" must be sha256 digest")
}
}
if result.EventCount > 0 && event.Cursor.Sequence < result.FirstCursor.Sequence || result.EventCount > 0 && event.Cursor.Sequence > result.LastCursor.Sequence {
violations = append(violations, prefix+".cursor.sequence is outside batch range")
}
if event.Cursor.SourceIdentityDigest != result.FirstCursor.SourceIdentityDigest || event.Cursor.StreamGeneration != result.FirstCursor.StreamGeneration {
violations = append(violations, prefix+".cursor must match batch source identity and generation")
}
violations = append(violations, validateSCUMValueMap(prefix+".payload", event.Payload, 64)...)
violations = append(violations, validateSCUMJSONSize(prefix+".payload", event.Payload, result.Limits.MaxPayloadBytes)...)
for key, value := range event.Payload {
if strings.Contains(strings.ToLower(key), "raw") || containsSCUMProtectedMaterial(key) || containsSCUMNetworkMaterial(key) {
violations = append(violations, prefix+".payload key is unsafe")
}
if text, ok := value.(string); ok && (containsSCUMProtectedMaterial(text) || containsSCUMNetworkMaterial(text) || containsSCUMRawXML(text)) {
violations = append(violations, prefix+".payload value is unsafe")
}
}
return violations
}
func validateSCUMTemplateKey(prefix, value string) []string {
var violations []string
violations = appendRequired(violations, prefix, value)
@@ -765,6 +894,15 @@ func validSCUMMutationReadbackStatus(value domain.SCUMMutationReadbackStatus) bo
}
}
func validSCUMLogTailState(value domain.SCUMLogTailState) bool {
switch value {
case domain.SCUMLogTailAdvanced, domain.SCUMLogTailRotated, domain.SCUMLogTailTruncated, domain.SCUMLogTailRestarted, domain.SCUMLogTailPartial, domain.SCUMLogTailReplayed:
return true
default:
return false
}
}
func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool {
switch value {
case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed:
@@ -821,3 +959,23 @@ func containsSCUMProtectedMaterial(value string) bool {
}
return false
}
func containsSCUMNetworkMaterial(value string) bool {
trimmed := strings.TrimSpace(value)
lowered := strings.ToLower(trimmed)
if trimmed == "" {
return false
}
if strings.Contains(lowered, "ip=") || strings.Contains(lowered, "addr=") || strings.Contains(lowered, "endpoint=") || strings.Contains(lowered, "port=") {
return true
}
if regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`).MatchString(trimmed) {
return true
}
for _, token := range regexp.MustCompile(`[\s,;()\[\]{}'"]+`).Split(trimmed, -1) {
if strings.Contains(token, ":") && net.ParseIP(strings.Trim(token, "<>")) != nil {
return true
}
}
return false
}
+57
View File
@@ -208,6 +208,27 @@ func TestValidateSCUMGuardedMutationResultRejectsMultiRowMissingReadbackAndUnsaf
}
}
func TestValidateSCUMParsedLogBatchResultAcceptsRotationSafeEnvelope(t *testing.T) {
result := validatorSCUMParsedLogBatchResult()
if err := ValidateSCUMParsedLogBatchResult(result); err != nil {
t.Fatalf("expected valid parsed log batch result, got %v", err)
}
}
func TestValidateSCUMParsedLogBatchResultRejectsRawLineNetworkMaterialAndLooseBounds(t *testing.T) {
result := validatorSCUMParsedLogBatchResult()
result.ParserDigest = ""
result.FirstCursor.SourceIdentityDigest = `C:\SCUM\Saved\Logs\login.log`
result.Events[0].Payload = map[string]any{"rawLine": "2026.08.13: '203.0.113.10 player' logged in"}
result.Limits.MaxLineBytes = 1000000
err := ValidateSCUMParsedLogBatchResult(result)
if err == nil || !strings.Contains(err.Error(), "parserDigest") || !strings.Contains(err.Error(), "sourceIdentityDigest") || !strings.Contains(err.Error(), "payload key is unsafe") || !strings.Contains(err.Error(), "payload value is unsafe") || !strings.Contains(err.Error(), "maxLineBytes") {
t.Fatalf("expected parsed-log safety violations, got %v", err)
}
}
func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *testing.T) {
evidence := domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilityPlayerRead, Status: domain.SCUMCapabilityEvidenceCompatible, Binding: validatorSCUMBinding(), AdapterVersion: "adapter-1", SchemaFingerprint: scumProbeHash, ProbeResultDigest: scumProbeHash, AssetDigests: []string{scumProbeHash}, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
if err := ValidateSCUMCapabilityEvidence(evidence); err != nil {
@@ -250,3 +271,39 @@ func validatorSCUMGuardedMutationResult() domain.SCUMGuardedMutationResult {
request := validatorSCUMGuardedMutationRequest()
return domain.SCUMGuardedMutationResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, SourceFingerprint: scumProbeHash, TargetIdentityDigest: request.TargetIdentityDigest, ExpectedRowDigest: request.ExpectedRowDigest, ExpectedValueDigest: request.ExpectedValueDigest, ExpectedXMLDigest: request.ExpectedXMLDigest, PatchDigest: request.PatchDigest, BackupEvidenceDigest: request.BackupEvidenceDigest, OfflineEvidenceDigest: request.OfflineEvidenceDigest, DangerConfirmationDigest: request.DangerConfirmationDigest, ReadbackExpectationDigest: request.ReadbackExpectationDigest, ObservedAt: time.Now(), ResultDigest: scumProbeHash, BeforeDigest: scumProbeHash, AfterDigest: scumProbeHash, ReadbackDigest: scumProbeHash, AffectedRows: 1, ReadbackStatus: domain.SCUMMutationReadbackConfirmed, SafeSummary: "confirmed by declared readback", SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}, Limits: request.Bounds}
}
func validatorSCUMParsedLogBatchResult() domain.SCUMParsedLogBatchResult {
cursor := domain.SCUMParsedLogCursor{SourceIdentityDigest: scumProbeHash, StreamGeneration: scumProbeHash, Sequence: 7}
return domain.SCUMParsedLogBatchResult{
RequestID: "request-log-1",
JobID: "job-log-1",
Binding: validatorSCUMBinding(),
Status: domain.SCUMTerminalResultSucceeded,
SourceKey: "scum-login-events",
StreamKey: "scum.login",
ParserKey: "scum-login-log-login-parser",
ParserVersion: "scum-login-log-v1",
AdapterVersion: "adapter-1",
AssetDigest: scumProbeHash,
ParserDigest: scumProbeHash,
ObservedAt: time.Now(),
ResultDigest: scumProbeHash,
FirstCursor: cursor,
LastCursor: cursor,
TailState: domain.SCUMLogTailRotated,
Replay: true,
EventCount: 1,
SafeSummary: "one sanitized login event parsed from declared source",
SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone},
Limits: domain.DefaultSCUMParsedLogBatchBounds(),
Events: []domain.SCUMParsedLogEvent{{
EventType: "scum.login",
OccurredAt: time.Now(),
Cursor: cursor,
LogicalEventDigest: scumProbeHash,
EventDigest: scumProbeHash,
PayloadDigest: scumProbeHash,
Payload: map[string]any{"externalPlayerId": "player-redacted", "displayName": "Known Player", "profileLocalId": "profile-redacted"},
}},
}
}