Publish evidence context in JSON output bundles
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,8 @@ const contentTypeJSON = "application/json"
|
||||
|
||||
const chunkMapFileName = "chunk-map.json"
|
||||
|
||||
const evidenceContextFileName = "evidence-context.json"
|
||||
|
||||
var safeOutputFileChar = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||
|
||||
var _ contracts.OutputEncoder = (*Encoder)(nil)
|
||||
@@ -223,14 +226,15 @@ func decodeEvidenceLaneIDs(value any) ([]string, error) {
|
||||
}
|
||||
|
||||
type indexFile struct {
|
||||
ManifestFile string `json:"manifest_file"`
|
||||
OutputFiles []outputFileIndex `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
ChunkMap *chunkMapIndex `json:"chunk_map,omitempty"`
|
||||
ManifestFile string `json:"manifest_file"`
|
||||
OutputFiles []outputFileIndex `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
ChunkMap *artifactIndex `json:"chunk_map,omitempty"`
|
||||
EvidenceContext *artifactIndex `json:"evidence_context,omitempty"`
|
||||
}
|
||||
|
||||
type chunkMapIndex struct {
|
||||
type artifactIndex struct {
|
||||
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
|
||||
File string `json:"file"`
|
||||
MediaType string `json:"media_type"`
|
||||
@@ -264,7 +268,7 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out
|
||||
})
|
||||
|
||||
outputIndexes := make([]outputFileIndex, 0, len(outputs))
|
||||
files := make([]contracts.OutputFile, 0, len(outputs)+4)
|
||||
files := make([]contracts.OutputFile, 0, len(outputs)+5)
|
||||
manifestFile, err := jsonFile("manifest.json", req.Manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -311,6 +315,14 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out
|
||||
files = append(files, chunkMapOutput)
|
||||
index.ChunkMap = &chunkMapDescriptor
|
||||
}
|
||||
if req.EvidenceContext != nil {
|
||||
evidenceOutput, evidenceDescriptor, err := serializedEvidenceContextFile(*req.EvidenceContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, evidenceOutput)
|
||||
index.EvidenceContext = &evidenceDescriptor
|
||||
}
|
||||
indexOutput, err := jsonFile("index.json", index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -330,24 +342,24 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func serializedChunkMapFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, chunkMapIndex, error) {
|
||||
func serializedChunkMapFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, artifactIndex, error) {
|
||||
if artifact.Kind != chunkmap.ArtifactKind {
|
||||
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unexpected artifact kind %q", artifact.Kind)
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unexpected artifact kind %q", artifact.Kind)
|
||||
}
|
||||
if artifact.Schema.ID != chunkmap.SchemaID || artifact.Schema.Name != chunkmap.SchemaName || artifact.Schema.Version != chunkmap.SchemaVersion {
|
||||
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unexpected schema identity")
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unexpected schema identity")
|
||||
}
|
||||
if strings.TrimSpace(artifact.MediaType) != chunkmap.MediaType {
|
||||
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unsupported media type %q", artifact.MediaType)
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unsupported media type %q", artifact.MediaType)
|
||||
}
|
||||
if _, err := chunkmap.New().Decode(artifact.Content); err != nil {
|
||||
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("decode chunk map: %w", err)
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("decode chunk map: %w", err)
|
||||
}
|
||||
file, err := serializedOutputFile(chunkMapFileName, artifact)
|
||||
if err != nil {
|
||||
return contracts.OutputFile{}, chunkMapIndex{}, err
|
||||
return contracts.OutputFile{}, artifactIndex{}, err
|
||||
}
|
||||
return file, chunkMapIndex{
|
||||
return file, artifactIndex{
|
||||
ArtifactKind: artifact.Kind,
|
||||
File: chunkMapFileName,
|
||||
MediaType: chunkmap.MediaType,
|
||||
@@ -357,6 +369,32 @@ func serializedChunkMapFile(artifact contracts.SerializedArtifact) (contracts.Ou
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serializedEvidenceContextFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, artifactIndex, error) {
|
||||
codec := evidencecontext.New()
|
||||
expected := codec.Schema()
|
||||
if artifact.Kind != evidencecontext.ArtifactKind ||
|
||||
artifact.Schema.ID != expected.ID || artifact.Schema.Name != expected.Name || artifact.Schema.Version != expected.Version ||
|
||||
contracts.DigestArtifactSchema(artifact.Schema) != contracts.DigestArtifactSchema(expected) ||
|
||||
strings.TrimSpace(artifact.MediaType) != evidencecontext.MediaType {
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
|
||||
}
|
||||
if _, err := codec.Decode(artifact.Content); err != nil {
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
|
||||
}
|
||||
file, err := serializedOutputFile(evidenceContextFileName, artifact)
|
||||
if err != nil {
|
||||
return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
|
||||
}
|
||||
return file, artifactIndex{
|
||||
ArtifactKind: evidencecontext.ArtifactKind,
|
||||
File: evidenceContextFileName,
|
||||
MediaType: evidencecontext.MediaType,
|
||||
SchemaID: expected.ID,
|
||||
SchemaName: expected.Name,
|
||||
SchemaVersion: expected.Version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serializedOutputFile(name string, artifact contracts.SerializedArtifact) (contracts.OutputFile, error) {
|
||||
content := append([]byte(nil), artifact.Content...)
|
||||
if len(content) == 0 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -338,6 +339,82 @@ func TestEncodeRejectsInvalidChunkMapArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeIncludesValidatedEvidenceContext(t *testing.T) {
|
||||
artifact := acceptedEvidenceContextArtifact(t)
|
||||
result, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &artifact})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
if got := string(fileBytes(t, result.Files, evidenceContextFileName)); !strings.HasSuffix(got, "\n") || !stdjson.Valid([]byte(got)) {
|
||||
t.Fatalf("evidence context file = %q, want pretty valid newline-terminated JSON", got)
|
||||
}
|
||||
value, err := evidencecontext.New().Decode(fileBytes(t, result.Files, evidenceContextFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(evidence context file) error = %v", err)
|
||||
}
|
||||
if len(value.Contexts) != 0 {
|
||||
t.Fatalf("evidence context = %#v, want explicit empty contexts", value)
|
||||
}
|
||||
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
||||
if got, want := index["evidence_context"], map[string]any{
|
||||
"artifact_kind": string(evidencecontext.ArtifactKind),
|
||||
"file": evidenceContextFileName,
|
||||
"media_type": evidencecontext.MediaType,
|
||||
"schema_id": evidencecontext.SchemaID,
|
||||
"schema_name": evidencecontext.SchemaName,
|
||||
"schema_version": evidencecontext.SchemaVersion,
|
||||
}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("evidence context descriptor = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, entry := range index["output_files"].([]any) {
|
||||
if entry.(map[string]any)["file"] == evidenceContextFileName {
|
||||
t.Fatalf("output_files = %#v, want no evidence context lane entry", index["output_files"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRejectsInvalidEvidenceContextArtifactWithoutContentLeakage(t *testing.T) {
|
||||
artifact := acceptedEvidenceContextArtifact(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*contracts.SerializedArtifact)
|
||||
}{
|
||||
{name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/evidence" }},
|
||||
{name: "schema identity", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }},
|
||||
{name: "schema digest", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.JSONSchema[0] = '[' }},
|
||||
{name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }},
|
||||
{name: "payload", mutate: func(artifact *contracts.SerializedArtifact) {
|
||||
artifact.Content = []byte(`{"source_id":"secret transcript text"}`)
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := contracts.CloneSerializedArtifact(artifact)
|
||||
test.mutate(&candidate)
|
||||
_, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &candidate})
|
||||
if err == nil || err.Error() != "json output encoder: evidence context artifact is invalid" {
|
||||
t.Fatalf("Encode() error = %v, want fixed evidence artifact error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret transcript text") {
|
||||
t.Fatalf("Encode() leaked artifact content: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeOmitsEvidenceContextWhenArtifactIsAbsent(t *testing.T) {
|
||||
result, err := New().Encode(context.Background(), contracts.OutputRequest{Manifest: artifacts.RunManifest{RunID: "run-1"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
if got := outputFileNames(result.Files); containsString(got, evidenceContextFileName) {
|
||||
t.Fatalf("file names = %#v, want no evidence context", got)
|
||||
}
|
||||
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
||||
if _, ok := index["evidence_context"]; ok {
|
||||
t.Fatalf("index = %#v, want no evidence context descriptor", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeChunkMapDoesNotMutateRequest(t *testing.T) {
|
||||
artifact := acceptedChunkMapArtifact(t)
|
||||
artifact.Metadata = map[string]any{"owner": "caller"}
|
||||
@@ -649,6 +726,33 @@ func acceptedChunkMapArtifactWithPlanAnnotation(t *testing.T, annotation stdjson
|
||||
return artifact
|
||||
}
|
||||
|
||||
func acceptedEvidenceContextArtifact(t *testing.T) contracts.SerializedArtifact {
|
||||
t.Helper()
|
||||
document := &source.SourceDocument{
|
||||
ID: "source-1",
|
||||
Kind: "text",
|
||||
Format: "text/plain",
|
||||
Units: []source.SourceUnit{{
|
||||
ID: 7,
|
||||
Kind: "text",
|
||||
Text: "Source content retained only in the evidence artifact.",
|
||||
Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7},
|
||||
}},
|
||||
}
|
||||
digest, err := source.DigestDocument(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document.Digest = digest
|
||||
artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{
|
||||
Source: document, WindowUnits: 3, SelectedLanes: []string{"spells"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func outputFileNames(files []contracts.OutputFile) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
|
||||
Reference in New Issue
Block a user