package artifacts import ( "fmt" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // ImmutableRunObjectKey identifies one clean object beneath a session's // immutable run namespace. type ImmutableRunObjectKey struct { RunID string RelativePath string } // PublishedOutputObjectKey identifies a published destination beneath one // immutable run in a session. type PublishedOutputObjectKey struct { RunID string Destination string } // ParseImmutableRunObjectKey proves that key belongs to one immutable run in // the expected campaign/session namespace and returns its run and relative path. func ParseImmutableRunObjectKey(sessionPrefix, key string) (ImmutableRunObjectKey, error) { if err := validateRemoteSessionPrefix(sessionPrefix); err != nil { return ImmutableRunObjectKey{}, err } if err := validateRemoteObjectKey(key); err != nil { return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q: %w", key, err) } runsPrefix := sessionPrefix + config.S3RunsSegment + "/" remainder, ok := strings.CutPrefix(key, runsPrefix) if !ok { return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q is outside session run prefix %q", key, runsPrefix) } runID, relativePath, ok := strings.Cut(remainder, "/") if !ok || runID == "" { return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q has an empty run identity", key) } if err := ValidateRunIdentity(runID); err != nil { return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q: %w", key, err) } if err := validateRemoteObjectKey(relativePath); err != nil { return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q has invalid run-relative path: %w", key, err) } return ImmutableRunObjectKey{RunID: runID, RelativePath: relativePath}, nil } // ParsePublishedOutputObjectKey proves that key is in the published-output // namespace of one immutable run in the expected session. func ParsePublishedOutputObjectKey(sessionPrefix, key string) (PublishedOutputObjectKey, error) { identity, err := ParseImmutableRunObjectKey(sessionPrefix, key) if err != nil { return PublishedOutputObjectKey{}, err } destination, ok := strings.CutPrefix(identity.RelativePath, "published/") if !ok || destination == "" { return PublishedOutputObjectKey{}, fmt.Errorf("immutable object key %q is outside its immutable run published-output prefix", key) } if err := validateRemoteObjectKey(destination); err != nil { return PublishedOutputObjectKey{}, fmt.Errorf("immutable object key %q has invalid published destination: %w", key, err) } return PublishedOutputObjectKey{RunID: identity.RunID, Destination: destination}, nil } // ValidateRemoteSessionPrefixIdentity proves that a canonical remote session // prefix names the expected campaign and session. func ValidateRemoteSessionPrefixIdentity(sessionPrefix, campaign, sessionID string) error { if err := validateRemoteSessionPrefix(sessionPrefix); err != nil { return err } if err := ValidateSessionIdentity(campaign, sessionID); err != nil { return err } parts := strings.Split(strings.TrimSuffix(sessionPrefix, "/"), "/") prefixCampaign := parts[len(parts)-3] prefixSessionID := parts[len(parts)-1] if prefixCampaign != campaign || prefixSessionID != sessionID { return fmt.Errorf( "session prefix %q identifies campaign/session %q/%q, want %q/%q", sessionPrefix, prefixCampaign, prefixSessionID, campaign, sessionID, ) } return nil } func validateCanonicalPublishSource(source string) error { classified, err := artifactpolicy.ClassifySource(source) if err != nil || classified.Kind == artifactpolicy.SourceKindPreviousArtifact { return fmt.Errorf("source %q is not a supported publish source", source) } if classified.ID != source { return fmt.Errorf("source %q is not canonical", source) } return nil } func validateRemoteSessionPrefix(sessionPrefix string) error { if sessionPrefix == "" || strings.TrimSpace(sessionPrefix) != sessionPrefix || strings.Contains(sessionPrefix, `\`) || !strings.HasSuffix(sessionPrefix, "/") { return fmt.Errorf("session prefix %q must be a canonical relative object prefix", sessionPrefix) } withoutSlash := strings.TrimSuffix(sessionPrefix, "/") if err := validateRemoteObjectKey(withoutSlash); err != nil { return fmt.Errorf("session prefix %q: %w", sessionPrefix, err) } parts := strings.Split(withoutSlash, "/") if len(parts) < 4 || parts[len(parts)-4] != config.S3CampaignsSegment || parts[len(parts)-2] != config.S3SessionsSegment { return fmt.Errorf("session prefix %q does not identify a campaign session", sessionPrefix) } if err := ValidateSessionIdentity(parts[len(parts)-3], parts[len(parts)-1]); err != nil { return fmt.Errorf("session prefix %q: %w", sessionPrefix, err) } return nil }