Permit safe cross-run output declarations
This commit is contained in:
@@ -114,7 +114,23 @@ func (m RemoteCommitManifest) ValidateForSessionPrefix(sessionPrefix string) err
|
||||
if runPrefix == "" {
|
||||
return fmt.Errorf("remote commit run prefix is required")
|
||||
}
|
||||
publishedDestinations := make(map[string]struct{})
|
||||
for _, artifact := range m.Artifacts {
|
||||
if artifact.Type == RemoteArtifactTypePublishedOutput {
|
||||
identity, err := ParseImmutableRunObjectKey(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[effectiveDestination]; duplicate {
|
||||
return fmt.Errorf("remote commit declares duplicate published output destination %q", effectiveDestination)
|
||||
}
|
||||
publishedDestinations[effectiveDestination] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(artifact.DestinationKey, runPrefix) {
|
||||
return fmt.Errorf("remote commit artifact destination %q is outside immutable run prefix %q", artifact.DestinationKey, runPrefix)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -46,6 +47,75 @@ func TestRemoteCommitDecodersRejectUnknownFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImmutableRunObjectKeyAcceptsSameSessionRuns(t *testing.T) {
|
||||
prefix := testCurrentSessionPrefix()
|
||||
for _, runID := range []string{"20260519T010203Z-a1b2c3d4", "20260401T010203Z-11223344"} {
|
||||
key := S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "published/artifacts/recap.md")
|
||||
identity, err := ParseImmutableRunObjectKey(prefix, key)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImmutableRunObjectKey(%q) error = %v", runID, err)
|
||||
}
|
||||
if identity.RunID != runID || identity.RelativePath != "published/artifacts/recap.md" {
|
||||
t.Fatalf("ParseImmutableRunObjectKey(%q) = %#v", runID, identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImmutableRunObjectKeyRejectsUnsafeOrMutableKeys(t *testing.T) {
|
||||
prefix := testCurrentSessionPrefix()
|
||||
for _, key := range []string{
|
||||
prefix + "current/commit-pointer.json",
|
||||
prefix + "locks/source.lock",
|
||||
prefix + "runs//published/recap.md",
|
||||
prefix + "runs/20260519T010203Z-a1b2c3d4",
|
||||
prefix + "runs/20260519T010203Z-a1b2c3d4/published/../recap.md",
|
||||
"dnd/campaigns/other/sessions/2026-05-03/runs/20260519T010203Z-a1b2c3d4/published/recap.md",
|
||||
} {
|
||||
if _, err := ParseImmutableRunObjectKey(prefix, key); err == nil {
|
||||
t.Fatalf("ParseImmutableRunObjectKey(%q) error = nil, want rejection", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommitAllowsPublishedOutputFromEarlierSameSessionRunOnly(t *testing.T) {
|
||||
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
||||
prefix := testCurrentSessionPrefix()
|
||||
published := RemoteArtifact{
|
||||
Type: RemoteArtifactTypePublishedOutput, Source: "narratio.artifact.recap",
|
||||
DestinationKey: S3RunRelativeDestinationKey(S3RunPrefix(prefix, "20260401T010203Z-11223344"), "published/artifacts/recap.md"),
|
||||
SHA256: strings.Repeat("a", 64), Size: 1, Generation: "older-generation",
|
||||
}
|
||||
commit.Artifacts = append(commit.Artifacts, published)
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err != nil {
|
||||
t.Fatalf("ValidateForSessionPrefix() error = %v", err)
|
||||
}
|
||||
|
||||
commit.Artifacts[1].DestinationKey = "dnd/campaigns/other/sessions/2026-05-03/runs/20260401T010203Z-11223344/published/artifacts/recap.md"
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
||||
t.Fatal("ValidateForSessionPrefix() cross-session error = nil")
|
||||
}
|
||||
commit.Artifacts[1].DestinationKey = published.DestinationKey
|
||||
commit.Artifacts[1].Type = RemoteArtifactTypeRunFile
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
||||
t.Fatal("ValidateForSessionPrefix() earlier non-published object error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommitRejectsEffectivePublishedDestinationCollisionAcrossRuns(t *testing.T) {
|
||||
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
||||
prefix := testCurrentSessionPrefix()
|
||||
for index, runID := range []string{commit.RunID, "20260401T010203Z-11223344"} {
|
||||
commit.Artifacts = append(commit.Artifacts, RemoteArtifact{
|
||||
Type: RemoteArtifactTypePublishedOutput, Source: fmt.Sprintf("narratio.artifact.recap_%d", index),
|
||||
DestinationKey: S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "published/artifacts/recap.md"),
|
||||
SHA256: strings.Repeat("a", 64), Size: 1, Generation: fmt.Sprintf("generation-%d", index),
|
||||
})
|
||||
}
|
||||
if err := commit.ValidateForSessionPrefix(prefix); err == nil || !strings.Contains(err.Error(), "duplicate published output destination") {
|
||||
t.Fatalf("ValidateForSessionPrefix() error = %v, want effective destination collision", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommitDecoderAcceptsHistoricalRunManifestArtifact(t *testing.T) {
|
||||
data := []byte(`{"format_version":1,"campaign":"sample-campaign","session_id":"2026-05-03","run_id":"20260519T010203Z-a1b2c3d4","artifacts":[{"type":"run_manifest","source":"run:manifest.json","destination_key":"sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4/manifest.json","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","size":1,"generation":"historical"}]}`)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -287,21 +286,7 @@ func remoteLocksPrefix(sessionPrefix string) (string, error) {
|
||||
}
|
||||
|
||||
func validateRemoteLockSessionPrefix(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
|
||||
return validateRemoteSessionPrefix(sessionPrefix)
|
||||
}
|
||||
|
||||
func remoteLockDigest(source string) string {
|
||||
|
||||
60
internal/artifacts/remote_object_key.go
Normal file
60
internal/artifacts/remote_object_key.go
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user