Add deterministic analysis input identities
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
@@ -30,6 +31,9 @@ type AnalyzeEvidence struct {
|
||||
SourceID string
|
||||
Path string
|
||||
ProducerRunID string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// InspectAnalyzeEvidence verifies that one configured artifact has supported,
|
||||
@@ -107,6 +111,9 @@ func InspectAnalyzeEvidence(
|
||||
SourceID: sourceID,
|
||||
Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)),
|
||||
ProducerRunID: record.ProducerRunID,
|
||||
Contract: cloneArtifactContract(record.Output.Contract),
|
||||
Checksum: record.Output.Checksum,
|
||||
Size: record.OutputSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +136,9 @@ func (c *ArtifactCatalog) HydrateAnalyzeArtifacts(
|
||||
if evidence.State != AnalyzeEvidenceCurrent {
|
||||
continue
|
||||
}
|
||||
_ = c.markAvailableFromAnalyzeManifest(evidence.SourceID, evidence.Path, evidence.ProducerRunID)
|
||||
_ = c.markAvailableFromAnalyzeManifest(
|
||||
evidence.SourceID, evidence.Path, evidence.ProducerRunID,
|
||||
evidence.Checksum, evidence.Size, evidence.Contract,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,18 @@ func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) {
|
||||
if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||
if entry.Checksum != record.Output.Checksum || entry.Size != record.OutputSize {
|
||||
t.Fatalf("entry content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, record.Output.Checksum, record.OutputSize)
|
||||
}
|
||||
if entry.Contract == nil || *entry.Contract != *record.Output.Contract {
|
||||
t.Fatalf("entry contract = %#v, want %#v", entry.Contract, record.Output.Contract)
|
||||
}
|
||||
entry.Contract.SchemaVersion = "mutated"
|
||||
again, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
|
||||
if again.Contract == nil || again.Contract.SchemaVersion != "1" {
|
||||
t.Fatalf("catalog contract was mutated through lookup: %#v", again.Contract)
|
||||
}
|
||||
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok {
|
||||
t.Fatal("removed manifest record was advertised in current catalog")
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ type ResolvedSessionArtifact struct {
|
||||
OutputKind string
|
||||
ProducerRunID string
|
||||
Provenance string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
||||
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
||||
OutputKind: entry.OutputKind,
|
||||
ProducerRunID: entry.ProducerRunID,
|
||||
Provenance: entry.Provenance,
|
||||
Contract: cloneArtifactContract(entry.Contract),
|
||||
Checksum: entry.Checksum,
|
||||
Size: entry.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
@@ -64,6 +65,9 @@ type CatalogEntry struct {
|
||||
Path string
|
||||
Provenance string
|
||||
ProducerRunID string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
||||
@@ -98,6 +102,7 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
||||
if _, exists := c.extractionIndex[trimmed]; exists {
|
||||
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
||||
}
|
||||
def := configured[key]
|
||||
sourceID := ExtractionArtifactSourceID(trimmed)
|
||||
if err := c.addEntry(CatalogEntry{
|
||||
SourceID: sourceID,
|
||||
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
||||
ProducerStage: "extract",
|
||||
OutputKind: "notarius_lane",
|
||||
Planned: true,
|
||||
Contract: &artifactmodel.ContractMetadata{
|
||||
MediaType: def.MediaType, SchemaID: def.SchemaID,
|
||||
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
||||
}
|
||||
@@ -221,7 +230,7 @@ func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) {
|
||||
return CatalogEntry{}, false
|
||||
}
|
||||
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
||||
return entry, ok
|
||||
return cloneCatalogEntry(entry), ok
|
||||
}
|
||||
|
||||
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
||||
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
|
||||
out := make([]CatalogEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
sourceID := c.configuredIndex[key]
|
||||
out = append(out, c.entries[sourceID])
|
||||
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
|
||||
sort.Strings(keys)
|
||||
out := make([]CatalogEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, c.entries[c.extractionIndex[key]])
|
||||
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -282,22 +291,36 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
|
||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(
|
||||
sourceID, path, producerRunID, checksum string,
|
||||
size int64,
|
||||
contract *artifactmodel.ContractMetadata,
|
||||
) error {
|
||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||
entry.Checksum = strings.TrimSpace(checksum)
|
||||
entry.Size = size
|
||||
entry.Contract = cloneArtifactContract(contract)
|
||||
c.entries[entry.SourceID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(sourceID, path, producerRunID string) error {
|
||||
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(
|
||||
sourceID, path, producerRunID, checksum string,
|
||||
size int64,
|
||||
contract *artifactmodel.ContractMetadata,
|
||||
) error {
|
||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||
entry.Checksum = strings.TrimSpace(checksum)
|
||||
entry.Size = size
|
||||
entry.Contract = cloneArtifactContract(contract)
|
||||
c.entries[entry.SourceID] = entry
|
||||
return nil
|
||||
}
|
||||
@@ -318,10 +341,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
|
||||
entry.Available = true
|
||||
entry.Path = trimmedPath
|
||||
entry.Provenance = provenance
|
||||
entry.ProducerRunID = ""
|
||||
entry.Checksum = ""
|
||||
entry.Size = 0
|
||||
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||
entry.Contract = nil
|
||||
}
|
||||
c.entries[normalizedID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneCatalogEntry(entry CatalogEntry) CatalogEntry {
|
||||
entry.Contract = cloneArtifactContract(entry.Contract)
|
||||
return entry
|
||||
}
|
||||
|
||||
func cloneArtifactContract(contract *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||
if contract == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *contract
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("artifact catalog is nil")
|
||||
|
||||
@@ -39,8 +39,11 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
|
||||
if proof.State != ExtractionEvidenceValid {
|
||||
return
|
||||
}
|
||||
for sourceID, path := range proof.Outputs {
|
||||
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
|
||||
for sourceID, output := range proof.Outputs {
|
||||
_ = c.markAvailableFromExtractManifest(
|
||||
sourceID, output.Path, proof.ProducerRunID,
|
||||
output.Checksum, output.Size, output.Contract,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
|
||||
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
||||
t.Fatalf("hydrated provenance = %#v", entry)
|
||||
}
|
||||
wantOutput := currentManifest.Stages["extract"].Outputs[0]
|
||||
info, err := os.Stat(wantOutput.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Checksum != wantOutput.Checksum || entry.Size != info.Size() {
|
||||
t.Fatalf("hydrated content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, wantOutput.Checksum, info.Size())
|
||||
}
|
||||
if entry.Contract == nil || *entry.Contract != *wantOutput.Contract {
|
||||
t.Fatalf("hydrated contract = %#v, want %#v", entry.Contract, wantOutput.Contract)
|
||||
}
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
@@ -23,7 +24,15 @@ const (
|
||||
type ExtractionEvidence struct {
|
||||
State ExtractionEvidenceState
|
||||
Reason, ProducerRunID string
|
||||
Outputs map[string]string
|
||||
Outputs map[string]ExtractionEvidenceOutput
|
||||
}
|
||||
|
||||
// ExtractionEvidenceOutput is the verified semantic identity of one lane.
|
||||
type ExtractionEvidenceOutput struct {
|
||||
Path string
|
||||
Checksum string
|
||||
Size int64
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
}
|
||||
|
||||
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
|
||||
@@ -72,7 +81,7 @@ func InspectExtractionEvidence(
|
||||
for key, d := range configured {
|
||||
expected[ExtractionArtifactSourceID(key)] = d
|
||||
}
|
||||
seen, outputs := map[string]struct{}{}, map[string]string{}
|
||||
seen, outputs := map[string]struct{}{}, map[string]ExtractionEvidenceOutput{}
|
||||
indexSeen := false
|
||||
for _, out := range r.Outputs {
|
||||
if strings.TrimSpace(out.ProducerRunID) != runID {
|
||||
@@ -82,7 +91,7 @@ func InspectExtractionEvidence(
|
||||
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
|
||||
}
|
||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
if state, reason, _ := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
return ExtractionEvidence{State: state, Reason: reason}
|
||||
}
|
||||
indexSeen = true
|
||||
@@ -98,11 +107,15 @@ func InspectExtractionEvidence(
|
||||
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
|
||||
}
|
||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
state, reason, size := inspectExtractionPayload(root, out.LocalPath, out.Checksum)
|
||||
if state != ExtractionEvidenceValid {
|
||||
return ExtractionEvidence{State: state, Reason: reason}
|
||||
}
|
||||
seen[out.SourceID] = struct{}{}
|
||||
outputs[out.SourceID] = out.LocalPath
|
||||
outputs[out.SourceID] = ExtractionEvidenceOutput{
|
||||
Path: out.LocalPath, Checksum: out.Checksum, Size: size,
|
||||
Contract: cloneArtifactContract(out.Contract),
|
||||
}
|
||||
}
|
||||
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
||||
@@ -110,30 +123,30 @@ func InspectExtractionEvidence(
|
||||
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
|
||||
}
|
||||
|
||||
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string) {
|
||||
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string, int64) {
|
||||
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
|
||||
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe"
|
||||
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe", 0
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ExtractionEvidenceObsolete, "extract output is missing"
|
||||
return ExtractionEvidenceObsolete, "extract output is missing", 0
|
||||
}
|
||||
if !safeExtractionComponents(root, path) {
|
||||
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components"
|
||||
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components", 0
|
||||
}
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return ExtractionEvidenceUnsafe, "extract output is not a regular file"
|
||||
return ExtractionEvidenceUnsafe, "extract output is not a regular file", 0
|
||||
}
|
||||
actual, err := SHA256File(path)
|
||||
if err != nil {
|
||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
|
||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read", 0
|
||||
}
|
||||
if actual != checksum {
|
||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes"
|
||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes", 0
|
||||
}
|
||||
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||
if err != nil || !json.Valid(body) {
|
||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON"
|
||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON", 0
|
||||
}
|
||||
return ExtractionEvidenceValid, ""
|
||||
return ExtractionEvidenceValid, "", info.Size()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user