Permit safe cross-run output declarations

This commit is contained in:
2026-09-05 12:37:58 +00:00
parent be59bff690
commit 145baac393
16 changed files with 363 additions and 81 deletions

View File

@@ -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)
}

View File

@@ -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"}]}`)

View File

@@ -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 {

View 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
}