Validate portable workspace identifiers
This commit is contained in:
36
internal/manifest/identity_validation_test.go
Normal file
36
internal/manifest/identity_validation_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalStoreRejectsUnsafeLegacyIdentity(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
data := []byte(`{"session_id":"../escape","campaign":"campaign","created_at":"` + now.Format(time.RFC3339Nano) + `","updated_at":"` + now.Format(time.RFC3339Nano) + `","stages":{}}`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := (&LocalStore{}).Load(context.Background(), path)
|
||||
if err == nil || !strings.Contains(err.Error(), "migrate the legacy manifest") {
|
||||
t.Fatalf("Load() error = %v, want migration guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStoreRefusesUnsafeIdentityOnSave(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
m := New("session", now)
|
||||
m.Campaign = "../outside"
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
|
||||
err := (&LocalStore{}).Save(context.Background(), path, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "migrate the legacy manifest") {
|
||||
t.Fatalf("Save() error = %v, want migration guidance", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
// Store persists manifests to and from durable storage.
|
||||
@@ -79,6 +80,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
||||
if m.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("save manifest: created_at is required")
|
||||
}
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return fmt.Errorf("save manifest: %w", err)
|
||||
}
|
||||
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if m.Stages == nil {
|
||||
@@ -202,6 +206,9 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
|
||||
if m.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("save run manifest: created_at is required")
|
||||
}
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return fmt.Errorf("save run manifest: %w", err)
|
||||
}
|
||||
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if m.Stages == nil {
|
||||
@@ -230,6 +237,9 @@ func validateLoadedManifest(m *Manifest) error {
|
||||
if m.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated_at is required")
|
||||
}
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -265,10 +275,40 @@ func validateLoadedRunManifest(m *RunManifest) error {
|
||||
if m.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated_at is required")
|
||||
}
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManifestIdentities(sessionID, campaign, runID string) error {
|
||||
identities := []struct {
|
||||
field string
|
||||
value string
|
||||
required bool
|
||||
}{
|
||||
{field: "session_id", value: sessionID, required: true},
|
||||
{field: "campaign", value: campaign},
|
||||
{field: "run_id", value: runID},
|
||||
}
|
||||
for _, identity := range identities {
|
||||
value := strings.TrimSpace(identity.value)
|
||||
if value == "" && !identity.required {
|
||||
continue
|
||||
}
|
||||
if err := pathsafe.ValidateOpaqueSegment(identity.value); err != nil {
|
||||
return fmt.Errorf(
|
||||
"manifest %s %q is not a portable opaque identifier; migrate the legacy manifest before use: %w",
|
||||
identity.field,
|
||||
identity.value,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRunManifest(m *RunManifest) {
|
||||
if m.Stages == nil {
|
||||
m.Stages = map[string]*RunStageRecord{}
|
||||
|
||||
Reference in New Issue
Block a user