package artifacts import ( "fmt" "strings" "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 } // 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 } 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 }