Centralize artifact source and publish destination policy
This commit is contained in:
145
internal/artifactpolicy/policy.go
Normal file
145
internal/artifactpolicy/policy.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package artifactpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceBoundsSession = "narratio.bounds.session"
|
||||
|
||||
configuredSourcePrefix = "narratio.artifact."
|
||||
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
|
||||
)
|
||||
|
||||
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||
var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||
|
||||
type SourceKind string
|
||||
|
||||
const (
|
||||
SourceKindBuiltIn SourceKind = "built_in"
|
||||
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
|
||||
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
|
||||
)
|
||||
|
||||
// Source describes one normalized artifact source identifier.
|
||||
type Source struct {
|
||||
ID string
|
||||
Kind SourceKind
|
||||
ConfiguredKey string
|
||||
}
|
||||
|
||||
// ConfiguredSourceID converts a configured artifact key into source id form.
|
||||
func ConfiguredSourceID(key string) string {
|
||||
return configuredSourcePrefix + 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.<key>.
|
||||
func ParseConfiguredSource(source string) (string, bool) {
|
||||
matches := configuredSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
||||
if len(matches) != 2 {
|
||||
return "", false
|
||||
}
|
||||
return matches[1], true
|
||||
}
|
||||
|
||||
// ParsePreviousSessionSource extracts configured key from narratio.previous_session.artifact.<key>.
|
||||
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 := ParsePreviousSessionSource(trimmed); ok {
|
||||
return Source{ID: trimmed, Kind: SourceKindPreviousArtifact, ConfiguredKey: key}, nil
|
||||
}
|
||||
return Source{}, fmt.Errorf("unsupported artifact source %q", source)
|
||||
}
|
||||
|
||||
// ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact.
|
||||
func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
|
||||
classified, err := ClassifySource(source)
|
||||
if err != nil {
|
||||
return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
|
||||
}
|
||||
if classified.Kind == SourceKindPreviousArtifact {
|
||||
return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
source, err := ValidatePublishSource(sourceID, configured)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(explicitDest) != "" {
|
||||
return pathsafe.NormalizeRelativeDestination(explicitDest)
|
||||
}
|
||||
return DeriveDefaultPublishedDestination(source, configured)
|
||||
}
|
||||
92
internal/artifactpolicy/policy_test.go
Normal file
92
internal/artifactpolicy/policy_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package artifactpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifySource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantKind SourceKind
|
||||
wantKey string
|
||||
wantErrLike string
|
||||
}{
|
||||
{name: "built in transcript", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
|
||||
{name: "built in bounds", source: "narratio.bounds.session", wantKind: SourceKindBuiltIn},
|
||||
{name: "configured artifact", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
|
||||
{name: "previous session configured", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap"},
|
||||
{name: "unsupported", source: "narratio.unknown", wantErrLike: "unsupported artifact source"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ClassifySource(tt.source)
|
||||
if tt.wantErrLike != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErrLike) {
|
||||
t.Fatalf("ClassifySource() error = %v, want like %q", err, tt.wantErrLike)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ClassifySource() error = %v", err)
|
||||
}
|
||||
if got.Kind != tt.wantKind {
|
||||
t.Fatalf("ClassifySource().Kind = %q, want %q", got.Kind, tt.wantKind)
|
||||
}
|
||||
if got.ConfiguredKey != tt.wantKey {
|
||||
t.Fatalf("ClassifySource().ConfiguredKey = %q, want %q", got.ConfiguredKey, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublishSource(t *testing.T) {
|
||||
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||
if _, err := ValidatePublishSource("narratio.artifact.session_recap", configured); err != nil {
|
||||
t.Fatalf("ValidatePublishSource(configured) error = %v", err)
|
||||
}
|
||||
if _, err := ValidatePublishSource("narratio.previous_session.artifact.session_recap", configured); err == nil {
|
||||
t.Fatal("ValidatePublishSource(previous) error = nil, want error")
|
||||
}
|
||||
if _, err := ValidatePublishSource("narratio.artifact.missing", configured); err == nil {
|
||||
t.Fatal("ValidatePublishSource(missing configured) error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePublishedDestination(t *testing.T) {
|
||||
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||
|
||||
got, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "", configured)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedDestination(built-in) error = %v", err)
|
||||
}
|
||||
if got != "transcripts/final.trimmed.json" {
|
||||
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
|
||||
}
|
||||
|
||||
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
|
||||
}
|
||||
if got != "artifacts/session_recap.md" {
|
||||
t.Fatalf("configured destination = %q, want artifacts/session_recap.md", got)
|
||||
}
|
||||
|
||||
got, err = ResolvePublishedDestination("narratio.transcript.final_trimmed", "published/../published/final.json", configured)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedDestination(explicit) error = %v", err)
|
||||
}
|
||||
if got != "published/final.json" {
|
||||
t.Fatalf("explicit destination = %q, want published/final.json", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) {
|
||||
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||
_, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "../escape.txt", configured)
|
||||
if err == nil {
|
||||
t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user