package main // What the tools show of the five kinds (novox/hq ADR 0329 §1, §11–§13): the profile's entries beside the declared // ones, with the operator's own values, and how a node differs from what is declared. Never a credential (§2): // every answer passes credentialChecked, which withholds a field whose name says password, token, secret or cookie // and the password part of any address. import ( "encoding/json" "fmt" "strings" ) // configView is the profile and the settings, read once for an answer. type configView struct { p Profile prefs map[string]string accounts map[string]Account ra []resolvedAccount conf Configuration problem string plan configPlan rec configRecord } func (m *Machine) view() (configView, error) { var v configView p, err := m.profile() if err != nil { return v, err } v.p, v.prefs = p, mergedPrefs(p.path) accounts, _, aerr := m.readAccounts() if aerr != nil { v.problem = aerr.Error() } v.accounts = accounts v.ra = resolveAccounts(accounts, v.prefs, m.readRecord(p.Dir)) conf, cerr := m.readConfiguration() if cerr != nil { v.problem = strings.TrimPrefix(v.problem+"; "+cerr.Error(), "; ") } v.conf = conf v.rec = m.readConfigRecord(p.Dir) v.plan = m.planConfiguration(p, conf, accounts, v.ra, v.prefs) return v, nil } // listedEntry is one entry of the profile, with its values: Owl's marked, an ICS calendar by its name only (§13). type listedEntry struct { Key string `json:"key"` Kind string `json:"kind,omitempty"` // Account is an identity's account key in the profile, or the account name filters are listed under. Account string `json:"account,omitempty"` Owl bool `json:"owl,omitempty"` // DeclaredAs is the declared entry this one is, or empty: the person's own. DeclaredAs string `json:"declared_as,omitempty"` Value any `json:"value,omitempty"` Note string `json:"note,omitempty"` } // declaredEntry is one declared entry, its value and how it resolved on this node. type declaredEntry struct { Name string `json:"name"` Value any `json:"value"` How string `json:"how,omitempty"` Key string `json:"key,omitempty"` Why string `json:"why,omitempty"` } // KindListing is what a listing tool answers. type KindListing struct { Profile string `json:"profile"` Kind string `json:"kind"` InProfile []listedEntry `json:"in_the_profile"` Declared []declaredEntry `json:"declared"` // Kept says where the module keeps the filter files it replaced, per account (§8). Kept map[string]string `json:"kept_filter_files,omitempty"` Problem string `json:"problem,omitempty"` } func (v configView) resolved(kind string) map[string]resolvedEntry { out := map[string]resolvedEntry{} for _, e := range v.plan.Entries { if e.Kind == kind { out[e.Name] = e } } return out } // declaredBy is, per profile key, the declared entry of a kind adopted or created under it. func (v configView) declaredBy(kind string) map[string]string { out := map[string]string{} for _, e := range v.plan.Entries { if e.Kind == kind && e.How != refused { out[e.Key] = e.Name } } return out } // List answers one kind: the profile's entries beside the declared ones. func (m *Machine) List(kind string) (KindListing, error) { v, err := m.view() a := KindListing{Kind: kind, InProfile: []listedEntry{}, Declared: []declaredEntry{}} if err != nil { return a, err } a.Profile, a.Problem = v.p.Dir, v.problem res := v.resolved(kind) by := v.declaredBy(kind) declare := func(name string, value any) { r := res[name] a.Declared = append(a.Declared, declaredEntry{Name: name, Value: value, How: r.How, Key: r.Key, Why: r.Why}) } switch kind { case kindIdentities: for _, pi := range m.profileIdentities(v.prefs) { a.InProfile = append(a.InProfile, listedEntry{Key: pi.Key, Account: pi.AccountKey, Owl: pi.Owl, DeclaredAs: by[pi.Key], Value: pi.Value}) } for _, name := range sortedKeys(v.conf.Identities) { declare(name, v.conf.Identities[name]) } case kindCalendars: for _, pc := range profileCalendars(v.prefs) { e := listedEntry{Key: pc.Key, Kind: pc.Kind, DeclaredAs: by[pc.Key], Value: pc.Value} switch { case strings.HasPrefix(pc.Kind, "owl"): e.Owl, e.Value = true, map[string]string{"name": pc.Value.Name, "url": pc.Value.URL} e.Note = "Owl's: never declared (novox/hq ADR 0316 §13)" case pc.Kind == "ics": e.Value = map[string]string{"name": pc.Value.Name} e.Note = "subscribed by an ICS address, which is often itself the secret: its name only, never declared (novox/hq ADR 0329 §13)" case pc.Kind != "caldav": e.Note = "kept in the profile: its events are never declared" } a.InProfile = append(a.InProfile, e) } for _, name := range sortedKeys(v.conf.Calendars) { declare(name, v.conf.Calendars[name]) } case kindAddressBooks: for _, b := range profileAddressBooks(v.prefs) { e := listedEntry{Key: b.Key, Kind: b.Kind, DeclaredAs: by[b.Key], Value: b.Value} if !b.CardDAV { e.Value, e.Note = map[string]string{"name": b.Value.Name}, "its contacts are kept in the profile and never declared" } a.InProfile = append(a.InProfile, e) } for _, name := range sortedKeys(v.conf.AddressBooks) { declare(name, v.conf.AddressBooks[name]) } case kindTags: for _, t := range profileTags(v.prefs) { a.InProfile = append(a.InProfile, listedEntry{Key: t.Key, DeclaredAs: by[t.Key], Value: t.Value}) } for _, key := range sortedKeys(v.conf.Tags) { declare(key, v.conf.Tags[key]) } case kindFilters: for _, f := range m.profileFilters(v) { a.InProfile = append(a.InProfile, f) } for _, account := range v.conf.sortedFilterAccounts() { d := declaredEntry{Name: account, Value: v.conf.Filters[account], How: "owned whole"} for _, fp := range v.plan.filters { if fp.Account == account { d.Key, d.Why = fp.File, strings.TrimSpace(fp.Refused+" "+fp.Waiting) if fp.Refused != "" { d.How = refused } } } a.Declared = append(a.Declared, d) } a.Kept = map[string]string{} for _, account := range sortedKeys(v.rec.Filters) { if r := v.rec.Filters[account]; r.Kept != "" { a.Kept[account] = r.Kept } } if len(a.Kept) == 0 { a.Kept = nil } default: return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", ")) } return a, nil } // profileFilters are the filters of each declared account in the profile, by the account's name. func (m *Machine) profileFilters(v configView) []listedEntry { var out []listedEntry for _, r := range v.ra { if r.How == refused { continue } dir := m.accountDirectory(v.p.path, v.prefs, r.Keys.Server) if dir == "" { continue } text, err := readBounded(dir + "/" + filterFileName) if err != nil { continue } parsed := parseFilters(string(text), folderBase(v.prefs, r.Keys.Server)) e := listedEntry{Key: m.tilde(strings.TrimPrefix(dir+"/"+filterFileName, m.Root)), Account: r.name, Value: parsed.Filters} if _, declared := v.conf.Filters[r.name]; declared { e.DeclaredAs = r.name } if len(parsed.NotDeclarable) > 0 { e.Note = "not declarable as they are: " + strings.Join(parsed.NotDeclarable, "; ") } out = append(out, e) } return out } // fieldDiff is one field a profile holds differently from the declared value. type fieldDiff struct { Field string `json:"field"` Declared any `json:"declared"` Profile any `json:"profile"` } // DriftEntry is one declared entry against the profile: holds, differs, lacks or refused (§11). type DriftEntry struct { Name string `json:"name"` State string `json:"state"` Key string `json:"key,omitempty"` Differs []fieldDiff `json:"differs,omitempty"` Why string `json:"why,omitempty"` } // KindDrift is one kind's drift, and the profile's entries nothing declares. type KindDrift struct { Kind string `json:"kind"` Declared []DriftEntry `json:"declared"` Undeclared []listedEntry `json:"undeclared,omitempty"` } // DriftAnswer is what betterbird_drift answers. type DriftAnswer struct { Profile string `json:"profile"` Kinds []KindDrift `json:"kinds"` Note string `json:"note"` Problem string `json:"problem,omitempty"` } // Drift answers, per kind, how the profile differs from what is declared: the profile as Betterbird reads it at // its next start (prefs.js with user.js over it), so a declared value the module placed and Betterbird has not // started with yet holds; the drift then is only what the module did not place. func (m *Machine) Drift(kind string) (DriftAnswer, error) { v, err := m.view() a := DriftAnswer{Kinds: []KindDrift{}} if err != nil { return a, err } a.Profile, a.Problem = v.p.Dir, v.problem a.Note = "the profile as Betterbird reads it at its next start (prefs.js, the module's user.js over it): differs " + "names each field with the declared value and the profile's; lacks is an entry the profile does not hold yet " + "(betterbird_user_js with apply, betterbird_restart); undeclared entries stay the person's" kinds := allKinds if kind != "" { if !contains(allKinds, kind) { return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", ")) } kinds = []string{kind} } for _, k := range kinds { a.Kinds = append(a.Kinds, m.kindDrift(v, k)) } return a, nil } func (m *Machine) kindDrift(v configView, kind string) KindDrift { d := KindDrift{Kind: kind, Declared: []DriftEntry{}} res := v.resolved(kind) by := v.declaredBy(kind) entry := func(name string, declared any, profile func(key string) (any, bool)) { r := res[name] e := DriftEntry{Name: name, Key: r.Key} switch { case r.How == refused: e.State, e.Why = refused, r.Why default: have, ok := profile(r.Key) if !ok { e.State = "lacks" break } e.Differs = differing(declared, have) e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0] } d.Declared = append(d.Declared, e) } switch kind { case kindIdentities: ids := map[string]profileIdentity{} for _, pi := range m.profileIdentities(v.prefs) { ids[pi.Key] = pi if by[pi.Key] == "" && !pi.Owl { d.Undeclared = append(d.Undeclared, listedEntry{Key: pi.Key, Account: pi.AccountKey, Value: pi.Value}) } } for _, name := range sortedKeys(v.conf.Identities) { want := *v.conf.Identities[name] want.Key = "" entry(name, want, func(key string) (any, bool) { pi, ok := ids[key] if !ok { return nil, false } have := pi.Value have.Account = want.Account if want.Address == "" { have.Address, have.Name = "", "" } return have, true }) } case kindCalendars: cals := map[string]profileCalendar{} for _, pc := range profileCalendars(v.prefs) { cals[pc.Key] = pc if by[pc.Key] == "" && pc.Kind == "caldav" { d.Undeclared = append(d.Undeclared, listedEntry{Key: pc.Key, Kind: pc.Kind, Value: pc.Value}) } } idName := v.declaredBy(kindIdentities) for _, name := range sortedKeys(v.conf.Calendars) { want := *v.conf.Calendars[name] want.Key = "" entry(name, want, func(key string) (any, bool) { pc, ok := cals[key] if !ok { return nil, false } have := pc.Value have.Invitations = idName[pc.IdentityKey] if have.Reminders == nil && want.Reminders != nil && *want.Reminders { on := true have.Reminders = &on } return have, true }) } case kindAddressBooks: books := map[string]profileAddressBook{} for _, b := range profileAddressBooks(v.prefs) { books[b.Key] = b if by[b.Key] == "" && b.CardDAV { d.Undeclared = append(d.Undeclared, listedEntry{Key: b.Key, Kind: b.Kind, Value: b.Value}) } } for _, name := range sortedKeys(v.conf.AddressBooks) { want := *v.conf.AddressBooks[name] want.Key = "" entry(name, want, func(key string) (any, bool) { b, ok := books[key] return b.Value, ok }) } case kindTags: tags := map[string]profileTag{} for _, t := range profileTags(v.prefs) { tags[t.Key] = t if by[t.Key] == "" { d.Undeclared = append(d.Undeclared, listedEntry{Key: t.Key, Value: t.Value}) } } for _, key := range sortedKeys(v.conf.Tags) { want := *v.conf.Tags[key] entry(key, want, func(k string) (any, bool) { t, ok := tags[k] have := t.Value if want.Order == 0 { have.Order = 0 } return have, ok }) } case kindFilters: files := map[string]listedEntry{} for _, f := range m.profileFilters(v) { files[f.Account] = f released := v.rec.Filters[f.Account].Released if f.DeclaredAs == "" && !released { if list, _ := f.Value.([]Filter); len(list) > 0 { d.Undeclared = append(d.Undeclared, f) } } } for _, fp := range v.plan.filters { e := DriftEntry{Name: fp.Account, Key: fp.File} switch { case fp.Refused != "": e.State, e.Why = refused, fp.Refused case fp.path == "" || !fp.exists: e.State, e.Why = "lacks", strings.TrimSpace(fp.Waiting) default: have, _ := files[fp.Account].Value.([]Filter) for _, line := range filterDifferences(v.conf.Filters[fp.Account], have) { e.Differs = append(e.Differs, fieldDiff{Field: line}) } if fp.have != fp.want && len(e.Differs) == 0 { e.Differs = append(e.Differs, fieldDiff{Field: "the file's text, beyond the filters' own words"}) } e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0] } d.Declared = append(d.Declared, e) } } return d } // differing are the fields of two values of the same shape that differ. func differing(declared, have any) []fieldDiff { var a, b map[string]any _ = json.Unmarshal([]byte(mustJSON(declared)), &a) _ = json.Unmarshal([]byte(mustJSON(have)), &b) var out []fieldDiff for _, f := range sortedKeys(mergeKeys(a, b)) { if mustJSON(a[f]) != mustJSON(b[f]) { out = append(out, fieldDiff{Field: f, Declared: a[f], Profile: b[f]}) } } return out } // driftCounts are, per kind, how many declared entries do not hold. func (m *Machine) driftCounts(v configView) map[string]int { out := map[string]int{} for _, k := range allKinds { for _, e := range m.kindDrift(v, k).Declared { if e.State != "holds" { out[k]++ } } } return out } // credentialChecked is an answer as it may leave the tool (§2): a field whose name says password, token, secret // or cookie withheld, and the password part of any address as ‹withheld›. func credentialChecked(v any) any { b, err := json.Marshal(v) if err != nil { return v } var tree any if json.Unmarshal(b, &tree) != nil { return v } return scrubTree(tree) } func scrubTree(n any) any { switch x := n.(type) { case map[string]any: for k, v := range x { if secretName.MatchString(k) { x[k] = "‹withheld›" continue } x[k] = scrubTree(v) } return x case []any: for i, v := range x { x[i] = scrubTree(v) } return x case string: return withoutPassword(x) } return n }