mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The module owned only the accounts' server fields, so identities, signatures, calendars, address books, filters and tags could differ between desktop nodes unseen, and the operator's own preferences were shipped in the catalogue. prefs becomes one entry per preference (a node's layer overrides one key) and the defaults file keeps no value of the operator's; identities, calendars, address-books, filters and tags become settings, adopted like accounts, filters owned whole per account, and what Betterbird rewrites written only while it is closed. New tools list each kind, derive the layers from every desktop node and propose one at a time, and show drift; every answer passes a credential check.
481 lines
15 KiB
Go
481 lines
15 KiB
Go
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
|
||
}
|