A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory
1package bundle
2
3import (
4 "encoding/json"
5 "fmt"
6 "time"
7
8 didplc "github.com/did-method-plc/go-didplc"
9 "tangled.org/atscan.net/plcbundle/plc"
10)
11
12// Validator validates PLC operations using go-didplc
13type Validator struct {
14 logger Logger
15}
16
17// InvalidOperation represents an operation that failed validation
18type InvalidOperation struct {
19 CID string
20 DID string
21 Reason string
22}
23
24// InvalidCallback is called immediately when an invalid operation is found
25type InvalidCallback func(InvalidOperation)
26
27// NewValidator creates a new validator
28func NewValidator(logger Logger) *Validator {
29 return &Validator{
30 logger: logger,
31 }
32}
33
34// ValidateBundleOperations validates all operations (simple version, returns error only)
35func (v *Validator) ValidateBundleOperations(ops []plc.PLCOperation) error {
36 _, err := v.ValidateBundleOperationsWithDetails(ops)
37 return err
38}
39
40// ValidateBundleOperationsWithDetails validates and returns details about invalid operations
41func (v *Validator) ValidateBundleOperationsWithDetails(ops []plc.PLCOperation) ([]InvalidOperation, error) {
42 var invalid []InvalidOperation
43
44 // Use streaming validation but collect results
45 err := v.ValidateBundleOperationsStreaming(ops, func(inv InvalidOperation) {
46 invalid = append(invalid, inv)
47 })
48
49 return invalid, err
50}
51
52// ValidateBundleOperationsStreaming validates and streams invalid operations via callback
53func (v *Validator) ValidateBundleOperationsStreaming(ops []plc.PLCOperation, callback InvalidCallback) error {
54 if len(ops) == 0 {
55 return nil
56 }
57
58 // First pass: validate each operation individually and parse
59 opsByDID := make(map[string][]didplc.LogEntry)
60 opCIDMap := make(map[string]string) // CID -> DID mapping
61 parseErrors := 0
62 validationErrors := 0
63
64 for _, op := range ops {
65 opCIDMap[op.CID] = op.DID
66
67 // Try to parse operation
68 var opEnum didplc.OpEnum
69 if err := parseOperationToEnum(op, &opEnum); err != nil {
70 if callback != nil {
71 callback(InvalidOperation{
72 CID: op.CID,
73 DID: op.DID,
74 Reason: fmt.Sprintf("parse error: %v", err),
75 })
76 }
77 parseErrors++
78 continue
79 }
80
81 // Create log entry
82 logEntry := didplc.LogEntry{
83 DID: op.DID,
84 CID: op.CID,
85 CreatedAt: op.CreatedAt.Format(time.RFC3339Nano),
86 Nullified: op.IsNullified(),
87 Operation: opEnum,
88 }
89
90 // Validate individual entry (checks CID match, signature for genesis, etc.)
91 if err := logEntry.Validate(); err != nil {
92 if callback != nil {
93 callback(InvalidOperation{
94 CID: op.CID,
95 DID: op.DID,
96 Reason: fmt.Sprintf("validation error: %v", err),
97 })
98 }
99 validationErrors++
100 // Still add to chain for chain validation (some errors might be at chain level)
101 }
102
103 opsByDID[op.DID] = append(opsByDID[op.DID], logEntry)
104 }
105
106 // Second pass: validate chains (chronological order, nullification, etc.)
107 chainErrors := 0
108 for did, entries := range opsByDID {
109 if err := didplc.VerifyOpLog(entries); err != nil {
110 // Chain validation failed - report which specific operations are affected
111 // Try to be more specific about which operations caused the failure
112 errMsg := err.Error()
113
114 if callback != nil {
115 // For chain errors, report all operations in the chain
116 // (we don't know which specific one caused it without more detailed analysis)
117 for _, entry := range entries {
118 callback(InvalidOperation{
119 CID: entry.CID,
120 DID: did,
121 Reason: fmt.Sprintf("chain error: %v", errMsg),
122 })
123 }
124 }
125 chainErrors++
126 }
127 }
128
129 totalErrors := parseErrors + validationErrors + chainErrors
130 if totalErrors > 0 {
131 return fmt.Errorf("%d parse errors, %d validation errors, %d chain errors",
132 parseErrors, validationErrors, chainErrors)
133 }
134
135 return nil
136}
137
138// parseOperationToEnum converts plc.PLCOperation to didplc.OpEnum
139func parseOperationToEnum(op plc.PLCOperation, opEnum *didplc.OpEnum) error {
140 // Try to use RawJSON first for exact parsing
141 if len(op.RawJSON) > 0 {
142 // Extract just the operation part from the full record
143 var fullRecord map[string]interface{}
144 if err := json.Unmarshal(op.RawJSON, &fullRecord); err != nil {
145 return fmt.Errorf("failed to unmarshal RawJSON: %w", err)
146 }
147
148 // Get the "operation" field
149 if opData, ok := fullRecord["operation"]; ok {
150 // Re-marshal just the operation data
151 data, err := json.Marshal(opData)
152 if err != nil {
153 return fmt.Errorf("failed to marshal operation: %w", err)
154 }
155
156 if err := json.Unmarshal(data, opEnum); err != nil {
157 return fmt.Errorf("failed to unmarshal into OpEnum: %w", err)
158 }
159 return nil
160 }
161 }
162
163 // Fallback: use the Operation map
164 data, err := json.Marshal(op.Operation)
165 if err != nil {
166 return fmt.Errorf("failed to marshal operation: %w", err)
167 }
168
169 if err := json.Unmarshal(data, opEnum); err != nil {
170 return fmt.Errorf("failed to unmarshal into OpEnum: %w", err)
171 }
172
173 return nil
174}