Add canonical source unit provenance
This commit is contained in:
35
internal/core/source/digest.go
Normal file
35
internal/core/source/digest.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DigestDocument returns a deterministic digest of the canonical source
|
||||
// document content. The existing Digest field is excluded from its own digest.
|
||||
func DigestDocument(doc *SourceDocument) (string, error) {
|
||||
if doc == nil {
|
||||
return "", fmt.Errorf("source document must not be nil")
|
||||
}
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Format string `json:"format"`
|
||||
Units []SourceUnit `json:"units"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}{
|
||||
ID: doc.ID,
|
||||
Kind: doc.Kind,
|
||||
Format: doc.Format,
|
||||
Units: doc.Units,
|
||||
Metadata: doc.Metadata,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode source document for digest: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type SourceUnit struct {
|
||||
ID int `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
Ref SourceRef `json:"ref"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,86 @@ func TestValidateDocumentDuplicateUnitIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocumentUnitReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*SourceDocument)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
mutate: func(doc *SourceDocument) { doc.Units[0].Ref = SourceRef{} },
|
||||
wantErr: "source unit[0].ref: source ref source_id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "foreign source",
|
||||
mutate: func(doc *SourceDocument) { doc.Units[0].Ref.SourceID = "source-2" },
|
||||
wantErr: "source unit[0].ref: source ref source_id \"source-2\" does not match document id \"source-1\"",
|
||||
},
|
||||
{
|
||||
name: "non-self range",
|
||||
mutate: func(doc *SourceDocument) {
|
||||
doc.Units[0].Ref.StartUnitID = 2
|
||||
doc.Units[0].Ref.EndUnitID = 2
|
||||
},
|
||||
wantErr: "source unit[0].ref must identify source unit id 1",
|
||||
},
|
||||
{
|
||||
name: "reversed range",
|
||||
mutate: func(doc *SourceDocument) {
|
||||
doc.Units[0].Ref.StartUnitID = 2
|
||||
doc.Units[0].Ref.EndUnitID = 1
|
||||
},
|
||||
wantErr: "source unit[0].ref: source ref start_unit_id 2 appears after end_unit_id 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
doc := validDocument()
|
||||
tt.mutate(doc)
|
||||
|
||||
err := ValidateDocument(doc)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateDocument() error = nil, want unit reference error")
|
||||
}
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Fatalf("ValidateDocument() error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestDocumentIsDeterministicAndIncludesUnitReference(t *testing.T) {
|
||||
doc := validDocument()
|
||||
doc.Metadata = map[string]any{"second": "value", "first": true}
|
||||
first, err := DigestDocument(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("DigestDocument() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
reordered := validDocument()
|
||||
reordered.Metadata = map[string]any{"first": true, "second": "value"}
|
||||
second, err := DigestDocument(reordered)
|
||||
if err != nil {
|
||||
t.Fatalf("DigestDocument(reordered) error = %v, want nil", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("digests = %q and %q, want deterministic map ordering", first, second)
|
||||
}
|
||||
|
||||
changed := validDocument()
|
||||
changed.Metadata = map[string]any{"first": true, "second": "value"}
|
||||
changed.Units[0].Ref.SourceID = "different-source"
|
||||
changedDigest, err := DigestDocument(changed)
|
||||
if err != nil {
|
||||
t.Fatalf("DigestDocument(changed) error = %v, want nil", err)
|
||||
}
|
||||
if first == changedDigest {
|
||||
t.Fatalf("digest = %q after reference change, want different digest", changedDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefValid(t *testing.T) {
|
||||
doc := validDocument()
|
||||
ref := SourceRef{
|
||||
@@ -274,11 +354,13 @@ func validDocument() *SourceDocument {
|
||||
ID: 1,
|
||||
Kind: "paragraph",
|
||||
Text: "First unit.",
|
||||
Ref: SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Kind: "paragraph",
|
||||
Text: "Second unit.",
|
||||
Ref: SourceRef{SourceID: "source-1", StartUnitID: 2, EndUnitID: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ func ValidateDocument(doc *SourceDocument) error {
|
||||
}
|
||||
seenUnitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
for i, unit := range doc.Units {
|
||||
if err := ValidateRef(doc, unit.Ref); err != nil {
|
||||
return fmt.Errorf("source unit[%d].ref: %w", i, err)
|
||||
}
|
||||
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
||||
return fmt.Errorf("source unit[%d].ref must identify source unit id %d", i, unit.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user