package catalog import ( "encoding/json" "sort" "strconv" ) // Diff types, mirroring octocatalog-diff's taxonomy. const ( DiffAdded = "+" // whole resource added DiffRemoved = "-" // whole resource removed DiffChanged = "~" // attribute changed DiffElemented = "!" // element changed inside a nested array or hash ) // Severities. const ( SeverityCritical = "critical" SeverityNormal = "normal" ) // criticalTypes are the resource types whose changes can break a host. var criticalTypes = map[string]bool{ "Service": true, "Package": true, "File": true, "Exec": true, "Mount": true, "User": true, "Group": true, "Cron": true, "Systemd::Unit_file": true, "Systemd::Service": true, "Docker::Image": true, "Docker::Container": true, } // IsCritical reports whether a resource type is in the critical set. func IsCritical(resourceType string) bool { return criticalTypes[resourceType] } // Severity buckets a resource type. func Severity(resourceType string) string { if IsCritical(resourceType) { return SeverityCritical } return SeverityNormal } // Change is one difference between the base and test catalogs. Structure is // the path to the changed value, e.g. ["parameters","device"]. type Change struct { DiffType string `json:"diff_type"` Type string `json:"type"` Title string `json:"title"` Structure []string `json:"structure"` OldValue any `json:"old_value"` NewValue any `json:"new_value"` OldFile string `json:"old_file,omitempty"` OldLine *int `json:"old_line,omitempty"` NewFile string `json:"new_file,omitempty"` NewLine *int `json:"new_line,omitempty"` Severity string `json:"severity"` // Datatype marks a change where only the datatype differs (28 vs "28"). Datatype bool `json:"datatype,omitempty"` } // Key is the "Type[Title]" identity of the resource the change belongs to. func (c Change) Key() string { return c.Type + "[" + c.Title + "]" } // Summary counts a change set. type Summary struct { Added int `json:"added"` Removed int `json:"removed"` Changed int `json:"changed"` Critical int `json:"critical"` } // DiffOptions controls what the comparison reports. type DiffOptions struct { // DisplayDatatypeChanges keeps changes where only the datatype differs. DisplayDatatypeChanges bool } // Diff compares two normalised catalogs. Whole-resource additions and removals // are determined from the key sets first, so a new resource is never confused // with a new element inside an existing one. file and line are reported for // context but never drive equality, so a resource moving line is not a change. // The result is sorted, so the same inputs always render identically. func Diff(base, test map[string]Resource, opts DiffOptions) []Change { var changes []Change for key, baseRes := range base { if _, ok := test[key]; ok { continue } changes = append(changes, Change{ DiffType: DiffRemoved, Type: baseRes.Type, Title: baseRes.Title, OldValue: baseRes.Params(), OldFile: baseRes.File, OldLine: baseRes.Line, Severity: Severity(baseRes.Type), }) } for key, testRes := range test { if _, ok := base[key]; ok { continue } changes = append(changes, Change{ DiffType: DiffAdded, Type: testRes.Type, Title: testRes.Title, NewValue: testRes.Params(), NewFile: testRes.File, NewLine: testRes.Line, Severity: Severity(testRes.Type), }) } for key, baseRes := range base { testRes, ok := test[key] if !ok { continue } changes = append(changes, diffParameters(baseRes, testRes, opts)...) } sort.SliceStable(changes, func(i, j int) bool { a, b := changes[i], changes[j] if a.Key() != b.Key() { return a.Key() < b.Key() } if a.DiffType != b.DiffType { return a.DiffType < b.DiffType } return structureKey(a.Structure) < structureKey(b.Structure) }) return changes } func structureKey(s []string) string { out := "" for _, part := range s { out += "\x00" + part } return out } func diffParameters(baseRes, testRes Resource, opts DiffOptions) []Change { proto := Change{ Type: testRes.Type, Title: testRes.Title, OldFile: baseRes.File, OldLine: baseRes.Line, NewFile: testRes.File, NewLine: testRes.Line, Severity: Severity(testRes.Type), } return walk(proto, []string{"parameters"}, anyMap(baseRes.Params()), anyMap(testRes.Params()), opts) } func anyMap(m map[string]any) any { out := make(map[string]any, len(m)) for k, v := range m { out[k] = v } return out } // walk compares two values at a path, recursing into matching containers so a // change deep inside a hash or array is reported as an element change ("!") // against the smallest structure that actually differs. func walk(proto Change, structure []string, oldVal, newVal any, opts DiffOptions) []Change { if deepEqual(oldVal, newVal) { return nil } oldMap, oldIsMap := oldVal.(map[string]any) newMap, newIsMap := newVal.(map[string]any) if oldIsMap && newIsMap { var changes []Change for _, k := range unionKeys(oldMap, newMap) { changes = append(changes, walk(proto, child(structure, k), oldMap[k], newMap[k], opts)...) } return changes } oldList, oldIsList := oldVal.([]any) newList, newIsList := newVal.([]any) if oldIsList && newIsList && len(oldList) == len(newList) { var changes []Change for i := range oldList { changes = append(changes, walk(proto, child(structure, strconv.Itoa(i)), oldList[i], newList[i], opts)...) } return changes } datatypeOnly := stringRepr(oldVal) == stringRepr(newVal) if datatypeOnly && !opts.DisplayDatatypeChanges { return nil } change := proto change.Structure = append([]string(nil), structure...) change.OldValue = oldVal change.NewValue = newVal change.Datatype = datatypeOnly // A difference directly on a parameter is an attribute change; anything // deeper is an element change inside that attribute. if len(structure) <= 2 { change.DiffType = DiffChanged } else { change.DiffType = DiffElemented } return []Change{change} } func unionKeys(a, b map[string]any) []string { seen := make(map[string]bool, len(a)+len(b)) keys := make([]string, 0, len(a)+len(b)) for k := range a { seen[k] = true keys = append(keys, k) } for k := range b { if !seen[k] { keys = append(keys, k) } } sort.Strings(keys) return keys } // child extends a structure path without aliasing the parent's backing array. func child(structure []string, part string) []string { out := make([]string, 0, len(structure)+1) return append(append(out, structure...), part) } // deepEqual compares two decoded JSON values structurally. Encoding both sides // sorts map keys, so map ordering never registers as a change. func deepEqual(a, b any) bool { ab, errA := json.Marshal(a) bb, errB := json.Marshal(b) if errA != nil || errB != nil { return false } return string(ab) == string(bb) } // Summarise counts a change set. func Summarise(changes []Change) Summary { var s Summary for _, c := range changes { switch c.DiffType { case DiffAdded: s.Added++ case DiffRemoved: s.Removed++ default: s.Changed++ } if c.Severity == SeverityCritical { s.Critical++ } } return s }