package artifactpolicy import ( "errors" "fmt" "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) const ( SourceBoundsSession = "narratio.bounds.session" SourceInputPlayers = "narratio.input.players" SourceInputParty = "narratio.input.party" SourceInputGlossary = "narratio.input.glossary" configuredSourcePrefix = "narratio.artifact." extractionSourcePrefix = "narratio.extraction." previousConfiguredSrcPrefix = "narratio.previous_session.artifact." ) var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) var extractionSourceRE = regexp.MustCompile(`^narratio\.extraction\.([a-z][a-z0-9_]*)$`) var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) var configuredKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) var ( ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source") ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format") ) type SourceKind string const ( SourceKindBuiltIn SourceKind = "built_in" SourceKindConfiguredArtifact SourceKind = "configured_artifact" SourceKindExtraction SourceKind = "extraction" SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact" SourceKindStableInput SourceKind = "stable_input" ) // Source describes one normalized artifact source identifier. type Source struct { ID string Kind SourceKind ConfiguredKey string } // ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source. type ScriptoriumInputSourceDescriptor struct { Source Source PreviousSession *PreviousSessionSourceDescriptor } // PreviousSessionSourceDescriptor describes one canonical previous-session input source. type PreviousSessionSourceDescriptor struct { SourceID string ConfiguredKey string ConfiguredSourceID string } // UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key. type UnknownConfiguredArtifactError struct { ConfiguredKey string } func (e *UnknownConfiguredArtifactError) Error() string { return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey) } // UnknownExtractionArtifactError reports a source that references an undefined extraction key. type UnknownExtractionArtifactError struct { ConfiguredKey string } func (e *UnknownExtractionArtifactError) Error() string { return fmt.Sprintf("references unknown extraction output %q", e.ConfiguredKey) } // IsConfiguredKey reports whether a key follows the configured-artifact key grammar. func IsConfiguredKey(key string) bool { return configuredKeyRE.MatchString(strings.TrimSpace(key)) } // ConfiguredSourceID converts a configured artifact key into source id form. func ConfiguredSourceID(key string) string { return configuredSourcePrefix + strings.TrimSpace(key) } // ExtractionSourceID converts an extraction output key into source id form. func ExtractionSourceID(key string) string { return extractionSourcePrefix + strings.TrimSpace(key) } // PreviousSessionSourceID converts a configured artifact key into previous-session source id form. func PreviousSessionSourceID(key string) string { return previousConfiguredSrcPrefix + strings.TrimSpace(key) } // ParseConfiguredSource extracts configured key from narratio.artifact.. func ParseConfiguredSource(source string) (string, bool) { matches := configuredSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // ParseExtractionSource extracts configured key from narratio.extraction.. func ParseExtractionSource(source string) (string, bool) { matches := extractionSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // ParsePreviousSessionSource extracts configured key from narratio.previous_session.artifact.. func ParsePreviousSessionSource(source string) (string, bool) { matches := previousSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // ClassifySource classifies a source id as built-in, configured, or previous-session configured. func ClassifySource(source string) (Source, error) { trimmed := strings.TrimSpace(source) if trimmed == "" { return Source{}, fmt.Errorf("artifact source is required") } if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil } if trimmed == SourceBoundsSession { return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil } if key, ok := ParseConfiguredSource(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindConfiguredArtifact, ConfiguredKey: key}, nil } if key, ok := ParseExtractionSource(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindExtraction, ConfiguredKey: key}, nil } if key, ok := ParsePreviousSessionSource(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindPreviousArtifact, ConfiguredKey: key}, nil } return Source{}, fmt.Errorf("unsupported artifact source %q", source) } // DescribeScriptoriumInputSource classifies one input source and returns descriptor // metadata used by config validation, analyze input resolution, and previous-cache planning. func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) { trimmed := strings.TrimSpace(source) if trimmed == "" { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } if IsStableInputSource(trimmed) { return ScriptoriumInputSourceDescriptor{ Source: Source{ID: trimmed, Kind: SourceKindStableInput}, }, nil } if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { descriptor, err := DescribePreviousSessionSource(trimmed) if err != nil { return ScriptoriumInputSourceDescriptor{}, err } return ScriptoriumInputSourceDescriptor{ Source: Source{ ID: descriptor.SourceID, Kind: SourceKindPreviousArtifact, ConfiguredKey: descriptor.ConfiguredKey, }, PreviousSession: &descriptor, }, nil } classified, err := ClassifySource(trimmed) if err != nil { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } return ScriptoriumInputSourceDescriptor{Source: classified}, nil } // IsStableInputSource reports whether source is a prepared stable input source // available only to Scriptorium input resolution. func IsStableInputSource(source string) bool { switch strings.TrimSpace(source) { case SourceInputPlayers, SourceInputParty, SourceInputGlossary: return true default: return false } } // DescribePreviousSessionSource validates a canonical previous-session source id // and returns both previous and configured-source vocabulary descriptors. func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) { configuredKey, ok := ParsePreviousSessionSource(source) if !ok { return PreviousSessionSourceDescriptor{}, ErrInvalidPreviousSessionSource } return PreviousSessionSourceDescriptor{ SourceID: PreviousSessionSourceID(configuredKey), ConfiguredKey: configuredKey, ConfiguredSourceID: ConfiguredSourceID(configuredKey), }, nil } // PreviousSessionSourceDescriptorForConfiguredKey derives a previous-session source descriptor // from a configured artifact key. func PreviousSessionSourceDescriptorForConfiguredKey(configuredKey string) (PreviousSessionSourceDescriptor, error) { return DescribePreviousSessionSource(PreviousSessionSourceID(configuredKey)) } // ValidateInputConfiguredReference checks that configured/previous-session sources // reference configured artifacts known to the current Scriptorium config. func ValidateInputConfiguredReference( descriptor ScriptoriumInputSourceDescriptor, configured map[string]struct{}, ) error { return ValidateInputReference(descriptor, configured, nil) } // ValidateInputReference checks that configured and extraction sources are declared // by the effective pipeline configuration. func ValidateInputReference( descriptor ScriptoriumInputSourceDescriptor, configured map[string]struct{}, extractions map[string]struct{}, ) error { switch descriptor.Source.Kind { case SourceKindConfiguredArtifact, SourceKindPreviousArtifact: if _, ok := configured[descriptor.Source.ConfiguredKey]; !ok { return &UnknownConfiguredArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey} } case SourceKindExtraction: if _, ok := extractions[descriptor.Source.ConfiguredKey]; !ok { return &UnknownExtractionArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey} } } return nil } // ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact. func ValidatePublishSource(source string, configured map[string]string) (Source, error) { return ValidatePublishSourceWithExtractions(source, configured, nil) } // ValidatePublishSourceWithExtractions validates publish sources against the // configured Scriptorium artifacts and extraction outputs. func ValidatePublishSourceWithExtractions( source string, configured map[string]string, extractions map[string]struct{}, ) (Source, error) { classified, err := ClassifySource(source) if err != nil { return Source{}, fmt.Errorf("must be a built-in source id, narratio.artifact., or configured narratio.extraction.") } if classified.Kind == SourceKindPreviousArtifact { return Source{}, fmt.Errorf("must be a built-in source id, narratio.artifact., or configured narratio.extraction.") } if classified.Kind == SourceKindConfiguredArtifact { if configured == nil { return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) } if _, ok := configured[classified.ConfiguredKey]; !ok { return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) } } if classified.Kind == SourceKindExtraction { if _, ok := extractions[classified.ConfiguredKey]; !ok { return Source{}, fmt.Errorf("extraction output %q is not defined in pipeline.notarius.outputs", classified.ConfiguredKey) } } return classified, nil } // DeriveDefaultPublishedDestination returns the default publish destination for one source. func DeriveDefaultPublishedDestination(source Source, configured map[string]string) (string, error) { switch source.Kind { case SourceKindBuiltIn: if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source.ID); ok { return pathsafe.NormalizeRelativeDestination(spec.CanonicalRelPath) } if source.ID == SourceBoundsSession { return pathsafe.NormalizeRelativeDestination("artifacts/session_bounds.json") } return "", fmt.Errorf("unsupported built-in source %q", source.ID) case SourceKindConfiguredArtifact: if configured == nil { return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) } outputPath, ok := configured[source.ConfiguredKey] if !ok { return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) } if strings.TrimSpace(outputPath) == "" { return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", source.ConfiguredKey) } return pathsafe.NormalizeRelativeDestination(outputPath) default: return "", fmt.Errorf("publish destination cannot be derived from source %q", source.ID) } } // ResolvePublishedDestination validates and normalizes an explicit destination, // or derives one when omitted. func ResolvePublishedDestination(sourceID, explicitDest string, configured map[string]string) (string, error) { return ResolvePublishedDestinationWithExtractions(sourceID, explicitDest, configured, nil) } // ResolvePublishedDestinationWithExtractions validates and normalizes a destination // while accepting extraction sources declared by the effective Notarius configuration. func ResolvePublishedDestinationWithExtractions( sourceID, explicitDest string, configured map[string]string, extractions map[string]struct{}, ) (string, error) { source, err := ValidatePublishSourceWithExtractions(sourceID, configured, extractions) if err != nil { return "", err } if strings.TrimSpace(explicitDest) != "" { return pathsafe.NormalizeRelativeDestination(explicitDest) } return DeriveDefaultPublishedDestination(source, configured) }