package manifest import ( "encoding/hex" "fmt" "sort" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) const ( // AnalyzeStateContractVersion identifies the supported per-artifact state // representation owned by the analyze stage. AnalyzeStateContractVersion = 1 // AnalyzeFingerprintContractVersion identifies the fingerprint representation // stored by the supported analyze state contract. AnalyzeFingerprintContractVersion = 1 ) const ( maxAnalyzeArtifactErrorLength = 512 maxAnalyzeArtifactTextLength = 4096 maxAnalyzeArtifactListEntries = 128 ) // AnalyzeArtifactStatus describes whether one configured analysis artifact is // currently available or why it is not. type AnalyzeArtifactStatus string const ( AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current" AnalyzeArtifactStale AnalyzeArtifactStatus = "stale" AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing" AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed" AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected" ) // AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation // identity without making adapter diagnostics part of the generic artifact schema. type AnalyzeArtifactProvenance struct { PromptID string `json:"prompt_id,omitempty"` ProfileID string `json:"profile_id,omitempty"` CommandMode string `json:"command_mode,omitempty"` } // AnalyzeArtifactRecord is analyze-owned state for one configured artifact. // Output is present only while the record is current. type AnalyzeArtifactRecord struct { Key string `json:"key"` Status AnalyzeArtifactStatus `json:"status"` FingerprintVersion int `json:"fingerprint_version,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` Family string `json:"family,omitempty"` CharacterID string `json:"character_id,omitempty"` Dependencies []string `json:"dependencies,omitempty"` Output *ArtifactRecord `json:"output,omitempty"` OutputSize int64 `json:"output_size,omitempty"` ProducerRunID string `json:"producer_run_id"` UpdatedAt time.Time `json:"updated_at"` Error string `json:"error,omitempty"` Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"` Logs []string `json:"logs,omitempty"` GeneratedConfigs []string `json:"generated_configs,omitempty"` } // HasVersionedAnalyzeState reports whether an analyze stage record carries the // supported per-artifact authority. A legacy aggregate-only record returns false. func (s *StageRecord) HasVersionedAnalyzeState() bool { return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion } // ValidateAnalyzeArtifactCollection validates one complete session or // invocation collection independently of its containing manifest. func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error { if version != AnalyzeStateContractVersion { return fmt.Errorf("unsupported analyze state version %d", version) } for key, record := range records { if err := validateAnalyzeArtifactRecord(key, record); err != nil { return fmt.Errorf("analyze artifact %q: %w", key, err) } } return nil } // CloneAnalyzeArtifactCollection returns a deep copy in canonical dependency // order so projections cannot be mutated after application. func CloneAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) map[string]AnalyzeArtifactRecord { if records == nil { return nil } cloned := make(map[string]AnalyzeArtifactRecord, len(records)) for key, record := range records { record.Dependencies = cloneStrings(record.Dependencies) record.Logs = cloneStrings(record.Logs) record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs) record.Output = cloneArtifactRecord(record.Output) record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium) cloned[key] = record } normalizeAnalyzeArtifactCollection(cloned) return cloned } func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error { if stageName != "analyze" { if version != 0 || records != nil { return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName) } return nil } if version == 0 { if records != nil { return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts") } return nil } return ValidateAnalyzeArtifactCollection(version, records) } func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error { if !isNormalizedAnalyzeArtifactKey(mapKey) { return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization") } if record.Key != mapKey { return fmt.Errorf("record key %q does not match map key", record.Key) } switch record.Status { case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected: default: return fmt.Errorf("unsupported status %q", record.Status) } if err := validateAnalyzeDependencies(record.Dependencies); err != nil { return err } if (record.Family == "") != (record.CharacterID == "") { return fmt.Errorf("family and character_id must be present together") } if record.Family != "" { if !artifactpolicy.IsConfiguredKey(record.Family) || !artifactpolicy.IsConfiguredKey(record.CharacterID) { return fmt.Errorf("family and character_id must match ^[a-z][a-z0-9_]*$") } } if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil { return err } if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil { return fmt.Errorf("producer_run_id is invalid: %w", err) } if record.UpdatedAt.IsZero() { return fmt.Errorf("updated_at is required") } if len(record.Error) > maxAnalyzeArtifactErrorLength { return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength) } if strings.TrimSpace(record.Error) != record.Error { return fmt.Errorf("error must be trimmed") } if record.Status == AnalyzeArtifactFailed { if record.Error == "" { return fmt.Errorf("failed status requires error") } } else if record.Error != "" { return fmt.Errorf("status %q forbids error", record.Status) } if record.Status == AnalyzeArtifactCurrent { if err := validateCurrentAnalyzeOutput(record); err != nil { return err } } else if record.Output != nil || record.OutputSize != 0 { return fmt.Errorf("status %q forbids output and output_size", record.Status) } if err := validateAnalyzeProvenance(record.Scriptorium); err != nil { return err } if err := validateAnalyzeTextList("logs", record.Logs); err != nil { return err } if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil { return err } return nil } func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error { if record.Output == nil { return fmt.Errorf("current status requires output") } if record.OutputSize <= 0 { return fmt.Errorf("current status requires positive output_size") } output := record.Output if strings.TrimSpace(output.Kind) == "" { return fmt.Errorf("current output kind is required") } if strings.TrimSpace(output.Kind) != output.Kind { return fmt.Errorf("current output kind must be trimmed") } wantSource := artifactpolicy.ConfiguredSourceID(record.Key) if output.SourceID != wantSource { return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource) } normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath) if err != nil { return fmt.Errorf("current output local_path is unsafe: %w", err) } if normalizedPath != output.LocalPath { return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath) } if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" { return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required") } for field, value := range map[string]string{ "media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID, "schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey, } { if strings.TrimSpace(value) != value { return fmt.Errorf("current output contract %s must be trimmed", field) } } if err := validateSHA256("current output checksum", output.Checksum); err != nil { return err } if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID { return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID) } for field, value := range map[string]string{ "output kind": output.Kind, "output source_id": output.SourceID, "output local_path": output.LocalPath, "output contract media_type": output.Contract.MediaType, "output contract schema_id": output.Contract.SchemaID, "output contract schema_version": output.Contract.SchemaVersion, "output contract module_key": output.Contract.ModuleKey, } { if err := validateAnalyzeText(field, value); err != nil { return err } } if output.ExternalProvenance != nil { if strings.TrimSpace(output.ExternalProvenance.System) == "" { return fmt.Errorf("output provenance system is required when provenance is present") } for field, value := range map[string]string{ "output provenance system": output.ExternalProvenance.System, "output provenance run_id": output.ExternalProvenance.RunID, "output provenance pipeline_id": output.ExternalProvenance.PipelineID, "output provenance artifact_id": output.ExternalProvenance.ArtifactID, } { if err := validateAnalyzeText(field, value); err != nil { return err } } } return nil } func validateAnalyzeDependencies(dependencies []string) error { seen := make(map[string]struct{}, len(dependencies)) for index, dependency := range dependencies { if !isNormalizedAnalyzeArtifactKey(dependency) { return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index) } if _, duplicate := seen[dependency]; duplicate { return fmt.Errorf("duplicate dependency %q", dependency) } seen[dependency] = struct{}{} } return nil } func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error { if version == 0 && fingerprint == "" { if required { return fmt.Errorf("current status requires fingerprint version and fingerprint") } return nil } if version != AnalyzeFingerprintContractVersion { return fmt.Errorf("unsupported fingerprint version %d", version) } return validateSHA256("fingerprint", fingerprint) } func validateSHA256(field, value string) error { if len(value) != 64 || strings.ToLower(value) != value { return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field) } decoded, err := hex.DecodeString(value) if err != nil || len(decoded) != 32 { return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field) } return nil } func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error { if provenance == nil { return nil } if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" { return fmt.Errorf("scriptorium provenance must contain at least one identifier") } for field, value := range map[string]string{ "scriptorium prompt_id": provenance.PromptID, "scriptorium profile_id": provenance.ProfileID, "scriptorium command_mode": provenance.CommandMode, } { if err := validateAnalyzeText(field, value); err != nil { return err } } return nil } func validateAnalyzeTextList(field string, values []string) error { if len(values) > maxAnalyzeArtifactListEntries { return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries) } for index, value := range values { if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil { return err } } return nil } func validateAnalyzeText(field, value string) error { if len(value) > maxAnalyzeArtifactTextLength { return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength) } if strings.ContainsRune(value, '\x00') { return fmt.Errorf("%s contains a NUL byte", field) } return nil } func isNormalizedAnalyzeArtifactKey(key string) bool { return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key) } func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) { for key, record := range records { if len(record.Dependencies) > 1 { record.Dependencies = append([]string(nil), record.Dependencies...) sort.Strings(record.Dependencies) } record.Logs = cloneStrings(record.Logs) record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs) record.Output = cloneArtifactRecord(record.Output) record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium) records[key] = record } } func cloneStrings(values []string) []string { return append([]string(nil), values...) } func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord { if record == nil { return nil } clone := *record if record.Contract != nil { contract := *record.Contract clone.Contract = &contract } if record.ExternalProvenance != nil { provenance := *record.ExternalProvenance clone.ExternalProvenance = &provenance } return &clone } func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance { if provenance == nil { return nil } clone := *provenance return &clone }