Snapshot verified references for extraction
This commit is contained in:
@@ -123,11 +123,23 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
|
||||
}
|
||||
for _, directory := range []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err)
|
||||
}
|
||||
|
||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input, references.Identities)
|
||||
if err != nil {
|
||||
@@ -136,7 +148,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
request := notarius.RunRequest{
|
||||
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: references.Bindings,
|
||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings,
|
||||
}
|
||||
adapterResult, err := env.Notarius.Run(ctx, request)
|
||||
if err != nil {
|
||||
@@ -148,6 +160,9 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
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 {
|
||||
return nil, fmt.Errorf("extract: verify Notarius reference snapshots: %w", err)
|
||||
}
|
||||
|
||||
selected, err := selectRequiredNotariusLanes(env.ArtifactStore, notariusConfig.Outputs, adapterResult)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||
)
|
||||
@@ -20,6 +26,86 @@ type extractReferenceIdentity struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
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))
|
||||
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 := fileops.OpenConfinedRegularFile(paths.Root, 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
|
||||
}
|
||||
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
|
||||
}
|
||||
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||
file, err := fileops.OpenConfinedRegularFile(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{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type resolvedExtractReferences struct {
|
||||
Bindings []notarius.ReferenceBinding
|
||||
Identities []extractReferenceIdentity
|
||||
|
||||
@@ -100,12 +100,12 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
|
||||
{selector: "party", sourceID: artifactpolicy.SourceInputParty, contents: "party-secret\n"},
|
||||
}
|
||||
env.Config.Pipeline.Notarius.References = make(map[string]string, len(configured))
|
||||
wantPaths := make(map[string]string, len(configured))
|
||||
preparedPaths := make(map[string]string, len(configured))
|
||||
wantSources := make(map[string]string, len(configured))
|
||||
for _, reference := range configured {
|
||||
env.Config.Pipeline.Notarius.References[reference.selector] = reference.sourceID
|
||||
selector := strings.TrimSpace(reference.selector)
|
||||
wantPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
|
||||
preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
|
||||
wantSources[selector] = reference.sourceID
|
||||
}
|
||||
|
||||
@@ -119,8 +119,14 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
|
||||
}
|
||||
for index, selector := range wantSelectors {
|
||||
binding := fake.Requests[0].References[index]
|
||||
if binding.Selector != selector || binding.Path != wantPaths[selector] || !filepath.IsAbs(binding.Path) {
|
||||
t.Fatalf("reference[%d] = %#v, want selector %q path %q", index, binding, selector, wantPaths[selector])
|
||||
wantPath := filepath.Join(
|
||||
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
|
||||
),
|
||||
filepath.Base(preparedPaths[selector]),
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +160,68 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
type referenceMutationRunner struct {
|
||||
delegate notarius.Runner
|
||||
preparedPath string
|
||||
preparedBytes []byte
|
||||
mutateSnapshot bool
|
||||
snapshotBytes []byte
|
||||
}
|
||||
|
||||
func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
|
||||
if err := os.WriteFile(r.preparedPath, r.preparedBytes, 0o664); err != nil {
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
if len(req.References) != 1 {
|
||||
return notarius.RunResult{}, fmt.Errorf("got %d references, want one", len(req.References))
|
||||
}
|
||||
data, err := os.ReadFile(req.References[0].Path)
|
||||
if err != nil {
|
||||
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 {
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
}
|
||||
return r.delegate.Run(ctx, req)
|
||||
}
|
||||
|
||||
func TestExtractStageInvokesNotariusWithVerifiedReferenceSnapshot(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")
|
||||
runner := &referenceMutationRunner{
|
||||
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"),
|
||||
}
|
||||
env.Notarius = runner
|
||||
|
||||
if _, err := (extractStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if string(runner.snapshotBytes) != "verified party\n" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageRejectsReferenceSnapshotChangedDuringNotariusRun(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"), mutateSnapshot: true,
|
||||
}
|
||||
|
||||
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 TestExtractStageRejectsUnavailableReferenceBeforeInvocationOrRunDirectoryCreation(t *testing.T) {
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user