From e13d87a077e8f35c6737e76957b245e6b3a511b3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 16 Sep 2026 17:53:29 +0000 Subject: [PATCH] Use stable verified Notarius reference snapshots --- docs/roadmap/implementation.md | 9 +- internal/app/extract_lifecycle_test.go | 14 +- internal/artifacts/paths.go | 36 +++- internal/artifacts/paths_model_test.go | 39 +++- internal/config/defaults.go | 1 + internal/stage/extract.go | 11 +- internal/stage/extract_references.go | 216 ++++++++++++++++------ internal/stage/extract_test.go | 244 +++++++++++++++++++++++-- 8 files changed, 469 insertions(+), 101 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 844e506..c432f7a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -123,7 +123,14 @@ has been introduced. Existing credential-redaction tests remain green. ## Stage 3 — Replace Run-Local Reference Snapshots with Stable Verified Snapshots -Status: Not started +Status: Complete (2026-09-16) + +Completion: Replaced run-ID reference copies with confined, content-addressed +session snapshots; added canonical cache path ownership, verified reuse, +reconstruction and regular-file repair, unsafe-conflict failures, narrow cleanup +of invalid new writes, and shared post-run verification. Removed the obsolete +run-local reference helper. Verified the required artifacts/stage/fileops/ +pathsafe tests and race tests for artifacts/stage. Read: `internal/artifacts/paths.go` and its tests; `internal/stage/extract_references.go`, `extract.go`, `extract_resume.go`, diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index 6e3f085..d780210 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -182,12 +182,16 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) { for index, expected := range want { binding := request.References[index] canonical := filepath.Join(paths.InputsDir, expected.filename) - snapshot := filepath.Join( - artifacts.SessionRunNotariusReferencesDirForCampaign( - cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID, - ), - expected.filename, + descriptor, ok := artifactpolicy.DescribePreparedInputSource(expected.sourceID) + if !ok { + t.Fatalf("DescribePreparedInputSource(%q) ok = false", expected.sourceID) + } + snapshot, err := artifacts.SessionNotariusReferenceSnapshotPath( + paths, lifecycleInputChecksum(t, loaded, descriptor.ManifestKind), expected.filename, ) + if err != nil { + t.Fatalf("SessionNotariusReferenceSnapshotPath(%q) error = %v", expected.sourceID, err) + } if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] { t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot) } diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 0a8f6c9..31a713c 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -10,6 +10,8 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) +const sha256HexLength = 64 + // SessionPaths contains canonical local paths for one session work directory. type SessionPaths struct { WorkspaceRoot string @@ -28,6 +30,7 @@ type SessionPaths struct { CurrentDir string RunsDir string PreviousDir string + CacheDir string PreviousManifestPath string PreviousArtifactsDir string ManifestPath string @@ -108,17 +111,37 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log") } -// SessionRunNotariusReferencesDirForCampaign returns the invocation-local -// directory containing verified reference snapshots supplied to Notarius. -func SessionRunNotariusReferencesDirForCampaign(rootDir, campaign, sessionID, runID string) string { - return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "references") -} - // SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root. func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string { return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output") } +// SessionNotariusReferenceCacheDir returns the stable cache root for verified +// reference snapshots supplied to Notarius. +func SessionNotariusReferenceCacheDir(paths SessionPaths) string { + return filepath.Join(paths.CacheDir, "notarius", "references") +} + +// SessionNotariusReferenceSnapshotPath returns the content-addressed path for +// one verified prepared reference snapshot. +func SessionNotariusReferenceSnapshotPath(paths SessionPaths, checksum, preparedBasename string) (string, error) { + if len(checksum) != sha256HexLength { + return "", fmt.Errorf("reference checksum must be a full lowercase SHA-256 digest") + } + for _, character := range checksum { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return "", fmt.Errorf("reference checksum must be a full lowercase SHA-256 digest") + } + } + if err := pathsafe.ValidateOpaqueSegment(preparedBasename); err != nil { + return "", fmt.Errorf("prepared reference basename: %w", err) + } + return pathsafe.JoinSlashRelativeUnderRoot( + SessionNotariusReferenceCacheDir(paths), + checksum+"/"+preparedBasename, + ) +} + // SessionNotariusBundleDirForCampaign returns one immutable durable bundle destination. func SessionNotariusBundleDirForCampaign(rootDir, campaign, sessionID, runID string) string { return filepath.Join( @@ -268,6 +291,7 @@ func buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root string) CurrentDir: filepath.Join(root, config.PathCurrentDirSegment), RunsDir: filepath.Join(root, config.PathRunsDirSegment), PreviousDir: filepath.Join(root, config.PathPreviousDirSegment), + CacheDir: filepath.Join(root, config.PathCacheDirSegment), PreviousManifestPath: filepath.Join(root, config.PathPreviousDirSegment, config.PathManifestFile), PreviousArtifactsDir: filepath.Join(root, config.PathPreviousDirSegment, config.PathArtifactsDirSegment), ManifestPath: filepath.Join(root, config.PathManifestFile), diff --git a/internal/artifacts/paths_model_test.go b/internal/artifacts/paths_model_test.go index 582b910..8323416 100644 --- a/internal/artifacts/paths_model_test.go +++ b/internal/artifacts/paths_model_test.go @@ -2,6 +2,7 @@ package artifacts import ( "path/filepath" + "strings" "testing" ) @@ -54,7 +55,6 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) { {name: "extract directory", got: SessionRunExtractDirForCampaign(root, campaign, sessionID, runID), want: extractDir}, {name: "receipt", got: SessionRunNotariusReceiptPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.receipt.json")}, {name: "stderr", got: SessionRunNotariusLogPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.stderr.log")}, - {name: "references", got: SessionRunNotariusReferencesDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "references")}, {name: "output root", got: SessionRunNotariusOutputRootForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius-output")}, {name: "durable bundle", got: SessionNotariusBundleDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(root, "work", campaign, sessionID, "artifacts", "notarius", runID)}, } @@ -67,6 +67,43 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) { } } +func TestSessionNotariusReferenceSnapshotPath(t *testing.T) { + paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19") + checksum := strings.Repeat("a", 64) + got, err := SessionNotariusReferenceSnapshotPath(paths, checksum, "party.yml") + if err != nil { + t.Fatalf("SessionNotariusReferenceSnapshotPath() error = %v", err) + } + wantRoot := filepath.Join(paths.Root, "cache", "notarius", "references") + if root := SessionNotariusReferenceCacheDir(paths); root != wantRoot { + t.Fatalf("SessionNotariusReferenceCacheDir() = %q, want %q", root, wantRoot) + } + if want := filepath.Join(wantRoot, checksum, "party.yml"); got != want { + t.Fatalf("SessionNotariusReferenceSnapshotPath() = %q, want %q", got, want) + } +} + +func TestSessionNotariusReferenceSnapshotPathRejectsUnsafeIdentity(t *testing.T) { + paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19") + validChecksum := strings.Repeat("a", 64) + for _, test := range []struct { + name, checksum, basename string + }{ + {name: "short digest", checksum: "abc", basename: "party.yml"}, + {name: "uppercase digest", checksum: strings.Repeat("A", 64), basename: "party.yml"}, + {name: "nonhex digest", checksum: strings.Repeat("g", 64), basename: "party.yml"}, + {name: "empty basename", checksum: validChecksum}, + {name: "nested basename", checksum: validChecksum, basename: "nested/party.yml"}, + {name: "parent basename", checksum: validChecksum, basename: ".."}, + } { + t.Run(test.name, func(t *testing.T) { + if got, err := SessionNotariusReferenceSnapshotPath(paths, test.checksum, test.basename); err == nil { + t.Fatalf("SessionNotariusReferenceSnapshotPath() = %q, want error", got) + } + }) + } +} + func TestSessionPreviousPathsForCampaign(t *testing.T) { root := "/tmp/workspace" previousDir := SessionPreviousDirForCampaign(root, "forsaken", "2026-04-19") diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 62ad992..5906ef2 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -78,6 +78,7 @@ const ( PathCurrentDirSegment = "current" PathRunsDirSegment = "runs" PathPreviousDirSegment = "previous" + PathCacheDirSegment = "cache" PathManifestFile = "manifest.json" PathLockFile = ".lock" PathTranscriptBase = artifactmodel.TranscriptPathBase diff --git a/internal/stage/extract.go b/internal/stage/extract.go index f7df307..3e6ae2e 100644 --- a/internal/stage/extract.go +++ b/internal/stage/extract.go @@ -124,20 +124,13 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re if err != nil { return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err) } - referenceSnapshotRoot, err := absolutePath(artifacts.SessionRunNotariusReferencesDirForCampaign(workspaceRoot, campaign, sessionID, runID)) - if err != nil { - return nil, fmt.Errorf("extract: resolve reference snapshot path: %w", err) - } directories := []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} - if len(references.Bindings) > 0 { - directories = append(directories, referenceSnapshotRoot) - } for _, directory := range directories { if err := fileops.EnsureWorkspaceDirectory(directory); err != nil { return nil, fmt.Errorf("extract: create directory %q: %w", directory, err) } } - referenceBindings, err := materializeExtractReferenceSnapshots(paths, references, referenceSnapshotRoot) + referenceBindings, err := materializeExtractReferenceSnapshots(paths, references) if err != nil { return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err) } @@ -182,7 +175,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID { return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible") } - if err := verifyExtractReferenceSnapshots(referenceSnapshotRoot, references); err != nil { + if err := verifyExtractReferenceSnapshots(paths, references); err != nil { return nil, fmt.Errorf("extract: verify Notarius reference snapshots: %w", err) } diff --git a/internal/stage/extract_references.go b/internal/stage/extract_references.go index cce89f0..4e66303 100644 --- a/internal/stage/extract_references.go +++ b/internal/stage/extract_references.go @@ -3,6 +3,7 @@ package stage import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "os" @@ -16,6 +17,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/notariusref" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) type extractReferenceIdentity struct { @@ -29,83 +31,179 @@ type extractReferenceIdentity struct { func materializeExtractReferenceSnapshots( paths artifacts.SessionPaths, references resolvedExtractReferences, - snapshotRoot string, ) ([]notarius.ReferenceBinding, error) { if len(references.Bindings) != len(references.Identities) { return nil, fmt.Errorf("reference bindings and identities have different lengths") } bindings := make([]notarius.ReferenceBinding, len(references.Bindings)) - copied := make(map[string]string, len(references.Bindings)) - destinationOwners := make(map[string]string, len(references.Bindings)) + materialized := make(map[string]extractReferenceIdentity, len(references.Bindings)) for index, binding := range references.Bindings { identity := references.Identities[index] - destination, ok := copied[identity.SourceID] - if !ok { - filename := filepath.Base(filepath.FromSlash(identity.Path)) - if filename == "" || filename == "." || filename == string(filepath.Separator) { - return nil, fmt.Errorf("reference %q source %q has invalid prepared filename", identity.Selector, identity.SourceID) - } - destination = filepath.Join(snapshotRoot, filename) - if owner, duplicate := destinationOwners[destination]; duplicate && owner != identity.SourceID { - return nil, fmt.Errorf("reference sources %q and %q resolve to the same snapshot path", owner, identity.SourceID) - } - destinationOwners[destination] = identity.SourceID - - file, err := os.Open(filepath.Join(paths.Root, filepath.FromSlash(identity.Path))) - if err != nil { - return nil, fmt.Errorf("open reference %q source %q for snapshot: %w", identity.Selector, identity.SourceID, err) - } - checksum, copyErr := fileops.WriteReaderAtomicWithChecksum(destination, file, fileops.WorkspaceFileMode) - closeErr := file.Close() - if copyErr != nil { - return nil, fmt.Errorf("snapshot reference %q source %q: %w", identity.Selector, identity.SourceID, copyErr) - } - if closeErr != nil { - return nil, fmt.Errorf("close reference %q source %q: %w", identity.Selector, identity.SourceID, closeErr) - } - info, err := os.Lstat(destination) - if err != nil { - return nil, fmt.Errorf("inspect reference %q snapshot: %w", identity.Selector, err) - } - if !info.Mode().IsRegular() || !strings.EqualFold(checksum, identity.Checksum) || info.Size() != identity.SizeBytes { - return nil, fmt.Errorf("reference %q source %q changed while its verified snapshot was created", identity.Selector, identity.SourceID) - } - copied[identity.SourceID] = destination + destination, err := extractReferenceSnapshotDestination(paths, identity) + if err != nil { + return nil, fmt.Errorf("reference %q source %q snapshot path: %w", identity.Selector, identity.SourceID, err) } + if previous, ok := materialized[destination]; ok { + if previous.Checksum != identity.Checksum || previous.SizeBytes != identity.SizeBytes { + return nil, fmt.Errorf("references %q and %q have conflicting identities for snapshot %q", previous.Selector, identity.Selector, destination) + } + bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination} + continue + } + if err := materializeExtractReferenceSnapshot(paths, identity, destination); err != nil { + return nil, err + } + materialized[destination] = identity bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination} } return bindings, nil } -func verifyExtractReferenceSnapshots(snapshotRoot string, references resolvedExtractReferences) error { - verified := make(map[string]struct{}, len(references.Identities)) - for _, identity := range references.Identities { - if _, ok := verified[identity.SourceID]; ok { - continue +func materializeExtractReferenceSnapshot(paths artifacts.SessionPaths, identity extractReferenceIdentity, destination string) error { + if err := checkExtractSnapshotDirectory(paths.Root, filepath.Dir(destination), true); err != nil { + return fmt.Errorf("reference %q snapshot cache: %w", identity.Selector, err) + } + info, err := os.Lstat(destination) + if err == nil && !info.Mode().IsRegular() { + return unsafeExtractSnapshotPath(destination, "the destination is not a regular file") + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect reference %q snapshot %q: %w", identity.Selector, destination, err) + } + if err == nil { + if verifyErr := verifyExtractReferenceFile(destination, identity); verifyErr == nil { + return nil } - filename := filepath.Base(filepath.FromSlash(identity.Path)) - file, err := os.Open(filepath.Join(snapshotRoot, filename)) - if err != nil { - return fmt.Errorf("reopen reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, err) - } - digest := sha256.New() - size, readErr := io.Copy(digest, file) - closeErr := file.Close() - if readErr != nil { - return fmt.Errorf("revalidate reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, readErr) - } - if closeErr != nil { - return fmt.Errorf("close reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, closeErr) - } - checksum := hex.EncodeToString(digest.Sum(nil)) - if !strings.EqualFold(checksum, identity.Checksum) || size != identity.SizeBytes { - return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running", identity.Selector, identity.SourceID) - } - verified[identity.SourceID] = struct{}{} + } + + source, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, identity.Path) + if err != nil { + return fmt.Errorf("resolve reference %q prepared source: %w", identity.Selector, err) + } + if err := verifyExtractReferenceFile(source, identity); err != nil { + return fmt.Errorf("reference %q prepared source %q no longer matches its manifest identity; rerun prepare with --force: %w", identity.Selector, source, err) + } + checksum, err := fileops.CopyFileAtomicWithChecksum(source, destination, fileops.WorkspaceFileMode) + if err != nil { + return fmt.Errorf("snapshot reference %q source %q to %q: %w", identity.Selector, identity.SourceID, destination, err) + } + if checksum != identity.Checksum { + return cleanupInvalidExtractSnapshot(paths, identity, destination, + fmt.Errorf("copied checksum %q does not match manifest checksum %q", checksum, identity.Checksum)) + } + if err := verifyExtractReferenceFile(destination, identity); err != nil { + return cleanupInvalidExtractSnapshot(paths, identity, destination, err) } return nil } +func cleanupInvalidExtractSnapshot(paths artifacts.SessionPaths, identity extractReferenceIdentity, destination string, verifyErr error) error { + err := fmt.Errorf("reference %q source %q changed while snapshot %q was created; rerun prepare with --force: %w", identity.Selector, identity.SourceID, destination, verifyErr) + if cleanupErr := fileops.RemoveFileUnderRoot(artifacts.SessionNotariusReferenceCacheDir(paths), destination); cleanupErr != nil { + return errors.Join(err, fmt.Errorf("remove invalid newly written snapshot %q: %w", destination, cleanupErr)) + } + return err +} + +func verifyExtractReferenceSnapshots(paths artifacts.SessionPaths, references resolvedExtractReferences) error { + verified := make(map[string]extractReferenceIdentity, len(references.Identities)) + for _, identity := range references.Identities { + destination, err := extractReferenceSnapshotDestination(paths, identity) + if err != nil { + return fmt.Errorf("reference %q source %q snapshot path: %w", identity.Selector, identity.SourceID, err) + } + if previous, ok := verified[destination]; ok { + if previous.Checksum != identity.Checksum || previous.SizeBytes != identity.SizeBytes { + return fmt.Errorf("references %q and %q have conflicting identities for snapshot %q", previous.Selector, identity.Selector, destination) + } + continue + } + if err := checkExtractSnapshotDirectory(paths.Root, filepath.Dir(destination), false); err != nil { + return fmt.Errorf("reference %q snapshot cache: %w", identity.Selector, err) + } + if err := verifyExtractReferenceFile(destination, identity); err != nil { + return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running at %q: %w", identity.Selector, identity.SourceID, destination, err) + } + verified[destination] = identity + } + return nil +} + +func extractReferenceSnapshotDestination(paths artifacts.SessionPaths, identity extractReferenceIdentity) (string, error) { + basename := filepath.Base(filepath.FromSlash(identity.Path)) + return artifacts.SessionNotariusReferenceSnapshotPath(paths, identity.Checksum, basename) +} + +func verifyExtractReferenceFile(path string, identity extractReferenceIdentity) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return unsafeExtractSnapshotPath(path, "the path is not a regular file") + } + file, err := os.Open(path) + if err != nil { + return err + } + digest := sha256.New() + size, readErr := io.Copy(digest, file) + closeErr := file.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + checksum := hex.EncodeToString(digest.Sum(nil)) + if checksum != identity.Checksum || size != identity.SizeBytes || info.Size() != identity.SizeBytes { + return fmt.Errorf("content identity is checksum %q size %d, want checksum %q size %d", checksum, size, identity.Checksum, identity.SizeBytes) + } + return nil +} + +func checkExtractSnapshotDirectory(sessionRoot, directory string, create bool) error { + relative, err := pathsafe.SlashRelativeFromRoot(sessionRoot, directory) + if err != nil { + return fmt.Errorf("resolve snapshot directory %q below session root: %w", directory, err) + } + root, err := os.OpenRoot(sessionRoot) + if err != nil { + return fmt.Errorf("open session root %q: %w", sessionRoot, err) + } + defer func() { _ = root.Close() }() + + current := "" + for _, component := range strings.Split(relative, "/") { + if current == "" { + current = component + } else { + current += "/" + component + } + info, err := root.Lstat(current) + if errors.Is(err, os.ErrNotExist) && create { + if err := root.Mkdir(current, fileops.WorkspaceDirectoryMode.Perm()); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("create snapshot cache directory %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err) + } + info, err = root.Lstat(current) + } + if err != nil { + return fmt.Errorf("inspect snapshot cache directory %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return unsafeExtractSnapshotPath(filepath.Join(sessionRoot, filepath.FromSlash(current)), "a path component is not a real directory") + } + if err := root.Chmod(current, fileops.WorkspaceDirectoryMode); err != nil { + return fmt.Errorf("set snapshot cache directory permissions %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err) + } + } + return nil +} + +func unsafeExtractSnapshotPath(path, reason string) error { + return fmt.Errorf("unsafe Notarius reference snapshot cache path %q: %s; remove or replace it only while the session is inactive", path, reason) +} + type resolvedExtractReferences struct { Bindings []notarius.ReferenceBinding Identities []extractReferenceIdentity diff --git a/internal/stage/extract_test.go b/internal/stage/extract_test.go index b83ea6d..75a1e6e 100644 --- a/internal/stage/extract_test.go +++ b/internal/stage/extract_test.go @@ -187,12 +187,16 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) } for index, selector := range wantSelectors { binding := fake.Requests[0].References[index] - wantPath := filepath.Join( - artifacts.SessionRunNotariusReferencesDirForCampaign( - env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID, - ), - filepath.Base(preparedPaths[selector]), + checksum, err := artifacts.SHA256File(preparedPaths[selector]) + if err != nil { + t.Fatalf("SHA256File(%q) error = %v", selector, err) + } + wantPath, err := artifacts.SessionNotariusReferenceSnapshotPath( + sessionPathsForEnv(env, m.SessionID), checksum, filepath.Base(preparedPaths[selector]), ) + if err != nil { + t.Fatalf("SessionNotariusReferenceSnapshotPath(%q) error = %v", selector, err) + } if binding.Selector != selector || binding.Path != wantPath || binding.Path == preparedPaths[selector] || !filepath.IsAbs(binding.Path) { t.Fatalf("reference[%d] = %#v, want selector %q verified snapshot %q", index, binding, selector, wantPath) } @@ -229,11 +233,11 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) } type referenceMutationRunner struct { - delegate notarius.Runner - preparedPath string - preparedBytes []byte - mutateSnapshot bool - snapshotBytes []byte + delegate notarius.Runner + preparedPath string + preparedBytes []byte + mutate func(string) error + snapshotBytes []byte } func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunRequest) (notarius.RunResult, error) { @@ -248,8 +252,8 @@ func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunReque return notarius.RunResult{}, err } r.snapshotBytes = data - if r.mutateSnapshot { - if err := os.WriteFile(req.References[0].Path, []byte("tampered snapshot\n"), 0o664); err != nil { + if r.mutate != nil { + if err := r.mutate(req.References[0].Path); err != nil { return notarius.RunResult{}, err } } @@ -272,21 +276,221 @@ func TestExtractStageInvokesNotariusWithVerifiedReferenceSnapshot(t *testing.T) t.Fatalf("Notarius reference bytes = %q, want verified snapshot", runner.snapshotBytes) } if len(fake.Requests) != 1 || fake.Requests[0].References[0].Path == preparedPath { - t.Fatalf("adapter requests = %#v, want a run-local reference snapshot", fake.Requests) + t.Fatalf("adapter requests = %#v, want a stable reference snapshot", fake.Requests) } } func TestExtractStageRejectsReferenceSnapshotChangedDuringNotariusRun(t *testing.T) { - env, m, fake := setupExtractEnv(t) + for _, test := range []struct { + name string + mutate func(string) error + }{ + {name: "content", mutate: func(path string) error { return os.WriteFile(path, []byte("tampered snapshot\n"), 0o664) }}, + {name: "missing", mutate: os.Remove}, + {name: "symlink", mutate: func(path string) error { + if err := os.Remove(path); err != nil { + return err + } + return os.Symlink(path+".outside", path) + }}, + {name: "directory", mutate: func(path string) error { + if err := os.Remove(path); err != nil { + return err + } + return os.Mkdir(path, 0o775) + }}, + } { + t.Run(test.name, func(t *testing.T) { + env, m, fake := setupExtractEnv(t) + env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty} + preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n") + env.Notarius = &referenceMutationRunner{ + delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutate: test.mutate, + } + + result, err := (extractStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil { + t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err) + } + }) + } +} + +func TestExtractReferenceSnapshotsAreStableReusableAndRecoverable(t *testing.T) { + env, m, _ := setupExtractEnv(t) env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty} - preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n") - env.Notarius = &referenceMutationRunner{ - delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutateSnapshot: true, + preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "stable party\n") + paths := sessionPathsForEnv(env, m.SessionID) + references, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID) + if err != nil { + t.Fatalf("resolveExtractReferences() error = %v", err) + } + first, err := materializeExtractReferenceSnapshots(paths, references) + if err != nil { + t.Fatalf("materializeExtractReferenceSnapshots(first) error = %v", err) + } + if len(first) != 1 { + t.Fatalf("first bindings = %#v", first) + } + firstPath := first[0].Path + + oldTime := time.Unix(123, 0) + if err := os.Chtimes(firstPath, oldTime, oldTime); err != nil { + t.Fatalf("Chtimes(snapshot) error = %v", err) + } + m.RunID = "20260810T020304Z-fedcba98" + second, err := materializeExtractReferenceSnapshots(paths, references) + if err != nil { + t.Fatalf("materializeExtractReferenceSnapshots(second run) error = %v", err) + } + if second[0].Path != firstPath { + t.Fatalf("snapshot path changed across run IDs: %q != %q", second[0].Path, firstPath) + } + info, err := os.Stat(firstPath) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(oldTime) { + t.Fatalf("verified snapshot was rewritten: modtime = %v, want %v", info.ModTime(), oldTime) } - result, err := (extractStage{}).Run(context.Background(), env, m) - if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil { - t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err) + if err := os.Remove(firstPath); err != nil { + t.Fatalf("Remove(snapshot) error = %v", err) + } + if _, err := materializeExtractReferenceSnapshots(paths, references); err != nil { + t.Fatalf("reconstruct missing snapshot error = %v", err) + } + assertFileContents(t, firstPath, "stable party\n") + + if err := os.WriteFile(firstPath, []byte("corrupt snapshot\n"), 0o664); err != nil { + t.Fatalf("WriteFile(corrupt snapshot) error = %v", err) + } + if _, err := materializeExtractReferenceSnapshots(paths, references); err != nil { + t.Fatalf("repair corrupt snapshot error = %v", err) + } + assertFileContents(t, firstPath, "stable party\n") + + if err := os.WriteFile(preparedPath, []byte("changed party bytes\n"), 0o664); err != nil { + t.Fatalf("WriteFile(changed prepared source) error = %v", err) + } + if err := os.WriteFile(firstPath, []byte("corrupt before source mismatch\n"), 0o664); err != nil { + t.Fatalf("WriteFile(corrupt snapshot) error = %v", err) + } + if _, err := materializeExtractReferenceSnapshots(paths, references); err == nil || !strings.Contains(err.Error(), "prepared source") { + t.Fatalf("materialize with changed prepared source error = %v, want prepared-source mismatch", err) + } + assertFileContents(t, firstPath, "corrupt before source mismatch\n") + + changedChecksum, err := artifacts.SHA256File(preparedPath) + if err != nil { + t.Fatalf("SHA256File(changed prepared source) error = %v", err) + } + descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty) + for index := range m.Inputs { + if m.Inputs[index].Kind == descriptor.ManifestKind { + m.Inputs[index].Checksum = changedChecksum + } + } + changed, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID) + if err != nil { + t.Fatalf("resolveExtractReferences(changed) error = %v", err) + } + changedBindings, err := materializeExtractReferenceSnapshots(paths, changed) + if err != nil { + t.Fatalf("materializeExtractReferenceSnapshots(changed) error = %v", err) + } + if changedBindings[0].Path == firstPath || filepath.Base(changedBindings[0].Path) != filepath.Base(firstPath) { + t.Fatalf("changed bytes paths = old %q new %q, want new digest path with same basename", firstPath, changedBindings[0].Path) + } + assertFileContents(t, firstPath, "corrupt before source mismatch\n") + assertFileContents(t, changedBindings[0].Path, "changed party bytes\n") +} + +func TestExtractReferenceSnapshotsDeduplicateEquivalentDestinations(t *testing.T) { + env, m, _ := setupExtractEnv(t) + path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "shared bytes\n") + checksum, err := artifacts.SHA256File(path) + if err != nil { + t.Fatal(err) + } + identity := extractReferenceIdentity{ + Selector: "party", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml", + Checksum: checksum, SizeBytes: int64(len("shared bytes\n")), + } + references := resolvedExtractReferences{ + Bindings: []notarius.ReferenceBinding{{Selector: "party"}, {Selector: "alternate"}}, + Identities: []extractReferenceIdentity{ + identity, + {Selector: "alternate", SourceID: artifactpolicy.SourceInputGlossary, Path: identity.Path, Checksum: checksum, SizeBytes: identity.SizeBytes}, + }, + } + bindings, err := materializeExtractReferenceSnapshots(sessionPathsForEnv(env, m.SessionID), references) + if err != nil { + t.Fatalf("materializeExtractReferenceSnapshots() error = %v", err) + } + if len(bindings) != 2 || bindings[0].Path != bindings[1].Path { + t.Fatalf("equivalent bindings = %#v, want shared destination", bindings) + } +} + +func TestExtractReferenceSnapshotsRejectUnsafeConflicts(t *testing.T) { + for _, test := range []struct { + name string + setup func(*testing.T, artifacts.SessionPaths, string) + }{ + {name: "leaf symlink", setup: func(t *testing.T, _ artifacts.SessionPaths, destination string) { + if err := os.MkdirAll(filepath.Dir(destination), 0o775); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(t.TempDir(), "outside"), destination); err != nil { + t.Fatal(err) + } + }}, + {name: "leaf directory", setup: func(t *testing.T, _ artifacts.SessionPaths, destination string) { + if err := os.MkdirAll(destination, 0o775); err != nil { + t.Fatal(err) + } + }}, + {name: "digest symlink", setup: func(t *testing.T, paths artifacts.SessionPaths, destination string) { + cacheRoot := artifacts.SessionNotariusReferenceCacheDir(paths) + if err := os.MkdirAll(cacheRoot, 0o775); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Dir(destination)); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + env, m, _ := setupExtractEnv(t) + env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty} + recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party bytes\n") + paths := sessionPathsForEnv(env, m.SessionID) + references, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID) + if err != nil { + t.Fatal(err) + } + destination, err := extractReferenceSnapshotDestination(paths, references.Identities[0]) + if err != nil { + t.Fatal(err) + } + test.setup(t, paths, destination) + _, err = materializeExtractReferenceSnapshots(paths, references) + if err == nil || !strings.Contains(err.Error(), artifacts.SessionNotariusReferenceCacheDir(paths)) || !strings.Contains(err.Error(), "session is inactive") { + t.Fatalf("materializeExtractReferenceSnapshots() error = %v, want actionable unsafe-path failure for %q", err, destination) + } + }) + } +} + +func assertFileContents(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + if string(data) != want { + t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want) } }