Validate published output declarations
This commit is contained in:
@@ -117,18 +117,14 @@ func (m RemoteCommitManifest) ValidateForSessionPrefix(sessionPrefix string) err
|
||||
publishedDestinations := make(map[string]struct{})
|
||||
for _, artifact := range m.Artifacts {
|
||||
if artifact.Type == RemoteArtifactTypePublishedOutput {
|
||||
identity, err := ParseImmutableRunObjectKey(sessionPrefix, artifact.DestinationKey)
|
||||
identity, err := ParsePublishedOutputObjectKey(sessionPrefix, artifact.DestinationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote commit published output destination %q: %w", artifact.DestinationKey, err)
|
||||
}
|
||||
effectiveDestination := strings.TrimPrefix(identity.RelativePath, "published/")
|
||||
if effectiveDestination == "" {
|
||||
return fmt.Errorf("remote commit published output destination %q is empty", artifact.DestinationKey)
|
||||
if _, duplicate := publishedDestinations[identity.Destination]; duplicate {
|
||||
return fmt.Errorf("remote commit declares duplicate published output destination %q", identity.Destination)
|
||||
}
|
||||
if _, duplicate := publishedDestinations[effectiveDestination]; duplicate {
|
||||
return fmt.Errorf("remote commit declares duplicate published output destination %q", effectiveDestination)
|
||||
}
|
||||
publishedDestinations[effectiveDestination] = struct{}{}
|
||||
publishedDestinations[identity.Destination] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(artifact.DestinationKey, runPrefix) {
|
||||
@@ -163,6 +159,11 @@ func validateRemoteArtifactIdentity(a RemoteArtifact) error {
|
||||
if strings.TrimSpace(a.Source) == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
if a.Type == RemoteArtifactTypePublishedOutput {
|
||||
if err := validateCanonicalPublishSource(a.Source); err != nil {
|
||||
return fmt.Errorf("published output %w", err)
|
||||
}
|
||||
}
|
||||
if err := validateRemoteObjectKey(a.DestinationKey); err != nil {
|
||||
return fmt.Errorf("destination key: %w", err)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,24 @@ func TestParseImmutableRunObjectKeyRejectsUnsafeOrMutableKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublishedOutputObjectKeyRequiresPublishedNamespace(t *testing.T) {
|
||||
prefix := testCurrentSessionPrefix()
|
||||
runID := "20260519T010203Z-a1b2c3d4"
|
||||
key := S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "published/artifacts/recap.md")
|
||||
identity, err := ParsePublishedOutputObjectKey(prefix, key)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublishedOutputObjectKey() error = %v", err)
|
||||
}
|
||||
if identity.RunID != runID || identity.Destination != "artifacts/recap.md" {
|
||||
t.Fatalf("ParsePublishedOutputObjectKey() = %#v", identity)
|
||||
}
|
||||
|
||||
outside := S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "artifacts/recap.md")
|
||||
if _, err := ParsePublishedOutputObjectKey(prefix, outside); err == nil {
|
||||
t.Fatal("ParsePublishedOutputObjectKey() outside published namespace error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommitAllowsPublishedOutputFromEarlierSameSessionRunOnly(t *testing.T) {
|
||||
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
||||
prefix := testCurrentSessionPrefix()
|
||||
@@ -99,6 +117,18 @@ func TestRemoteCommitAllowsPublishedOutputFromEarlierSameSessionRunOnly(t *testi
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
||||
t.Fatal("ValidateForSessionPrefix() earlier non-published object error = nil")
|
||||
}
|
||||
|
||||
commit.Artifacts[1] = published
|
||||
commit.Artifacts[1].DestinationKey = S3RunRelativeDestinationKey(S3RunPrefix(prefix, commit.RunID), "artifacts/recap.md")
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
||||
t.Fatal("ValidateForSessionPrefix() published output outside published namespace error = nil")
|
||||
}
|
||||
|
||||
commit.Artifacts[1] = published
|
||||
commit.Artifacts[1].Source = "run:artifacts/recap.md"
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
||||
t.Fatal("ValidateForSessionPrefix() invalid published source error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommitRejectsEffectivePublishedDestinationCollisionAcrossRuns(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -66,7 +65,7 @@ func (l RemoteLock) Validate() error {
|
||||
if l.FormatVersion != RemoteLockFormatVersion {
|
||||
return fmt.Errorf("unsupported remote lock format version %d", l.FormatVersion)
|
||||
}
|
||||
if err := validateRemoteLockSource(l.Source); err != nil {
|
||||
if err := validateCanonicalPublishSource(l.Source); err != nil {
|
||||
return fmt.Errorf("remote lock source: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(l.Reason) != l.Reason {
|
||||
@@ -137,7 +136,7 @@ func RemoteLockKey(sessionPrefix, source string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateRemoteLockSource(source); err != nil {
|
||||
if err := validateCanonicalPublishSource(source); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return locksPrefix + remoteLockDigest(source) + ".lock", nil
|
||||
@@ -267,17 +266,6 @@ func lookupRemoteLockAtKey(ctx context.Context, store storage.ObjectStore, key,
|
||||
return RemoteLockLookup{Lock: lock, Present: true, Generation: info.ETag}, nil
|
||||
}
|
||||
|
||||
func validateRemoteLockSource(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 remoteLocksPrefix(sessionPrefix string) (string, error) {
|
||||
if err := validateRemoteLockSessionPrefix(sessionPrefix); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,13 @@ type ImmutableRunObjectKey struct {
|
||||
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) {
|
||||
@@ -41,6 +49,34 @@ func ParseImmutableRunObjectKey(sessionPrefix, key string) (ImmutableRunObjectKe
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user