package main // The setting prefs, one entry per preference (novox/hq issue 586, ADR 0329; ADR 0316 §11 for the user.js). // // Until issue 586 the setting was one line of user_pref(…) statements, so a node's layer replaced it whole and // the operator's own choices were shipped in the catalogue as the module's defaults file. Now the setting is a // map from a preference's name to its value, and reaches the machine as ~/.config/betterbird/prefs.json, a // file the node-engine merges from the module's layers key by key: the mesh-wide layer holds the operator's // setup, and a node's layer overrides one preference, or takes one out with null. The module itself ships only // its policy's locks and Betterbird's own defaults (mesh-defaults.js names the preferences it manages there). // // The tools turn the setting into the head of the user.js they place in the profile, where Betterbird reads it // at its start and lets it win over what it saved itself. A preference that can hold a credential, or that // another setting owns (an account's, an identity's, a calendar's, an address book's, a tag's), is refused, so // each value has one owner (ADR 0316 §11, ADR 0329 §6). import ( "bytes" "encoding/json" "errors" "fmt" "math" "os" "regexp" "sort" "strconv" "strings" ) // prefsFile is the setting prefs on the machine, written by the node-engine. const prefsFile = ".config/betterbird/prefs.json" // configurationOwned are the preferences the settings of ADR 0329 own: the setting prefs refuses them (§6). var configurationOwned = []string{"calendar.registry.", "calendar.list.", "ldap_2.servers.", "mailnews.tags."} // prefName is a preference's name as the setting may give it. var prefName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.\-\[\]@{}]{0,199}$`) // prefOwner says why the setting prefs may not set a preference, or "" when it may. func prefOwner(name string) string { if passwordSecret.MatchString(name) { return "a preference that can hold a credential: not through the mesh" } for _, p := range accountOwned { if strings.HasPrefix(name, p) { return "an account's preference: an account is declared in the setting accounts, never in prefs (novox/hq ADR 0316 §11)" } } for _, p := range configurationOwned { if strings.HasPrefix(name, p) { return "owned by the setting calendars, address-books or tags, never by prefs (novox/hq ADR 0329 §6)" } } return "" } // readDeclaredPrefs reads the setting prefs on the machine: each preference's value as user.js writes it. A // missing file says the module is not pushed here in this form; a file that names a preference another setting // owns, or holds a value user.js cannot carry, is refused whole, naming the preference. func (m *Machine) readDeclaredPrefs() (map[string]string, bool, error) { raw, err := readBounded(m.home(prefsFile)) if errors.Is(err, os.ErrNotExist) { return map[string]string{}, false, nil } if err != nil { return nil, false, err } prefs, err := parseDeclaredPrefs(raw) return prefs, true, err } func parseDeclaredPrefs(raw []byte) (map[string]string, error) { var top map[string]json.RawMessage if err := json.Unmarshal(raw, &top); err != nil { return nil, fmt.Errorf("~/%s is not a JSON object: %v", prefsFile, err) } out := map[string]string{} body, ok := top["prefs"] if !ok || string(body) == "null" { return out, nil } var values map[string]any dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() if err := dec.Decode(&values); err != nil { return nil, fmt.Errorf("the setting prefs is a map from a preference's name to its value: %v", err) } var problems []string for _, name := range sortedKeys(values) { if !prefName.MatchString(name) { problems = append(problems, fmt.Sprintf("%q is not a preference's name", name)) continue } if why := prefOwner(name); why != "" { problems = append(problems, name+": "+why) continue } lit, err := prefLiteral(values[name]) if err != nil { problems = append(problems, name+": "+err.Error()) continue } if lit != "" { out[name] = lit } } if len(problems) > 0 { return nil, fmt.Errorf("the setting prefs is refused: %s", strings.Join(problems, "; ")) } return out, nil } // prefLiteral is a setting's value as user.js writes it: true or false, a whole number, or a string. null is // no value: a node's layer takes the mesh-wide one out with it, and Betterbird's own default holds there. func prefLiteral(v any) (string, error) { switch x := v.(type) { case nil: return "", nil case bool: return strconv.FormatBool(x), nil case json.Number: n, err := x.Int64() if err != nil || n > math.MaxInt32 || n < math.MinInt32 { return "", fmt.Errorf("%s is not a whole number Betterbird keeps", x) } return strconv.FormatInt(n, 10), nil case float64: if x != math.Trunc(x) || x > math.MaxInt32 || x < math.MinInt32 { return "", fmt.Errorf("%v is not a whole number Betterbird keeps", x) } return strconv.FormatInt(int64(x), 10), nil case string: if strings.ContainsAny(x, lineEnds) { return "", errors.New("a string preference is one line") } return jsQuoted(x), nil } return "", fmt.Errorf("a value is true, false, a whole number or a string, not %T", v) } // prefValue is a preference's literal as the setting holds it: the reverse of prefLiteral. func prefValue(lit string) (any, bool) { switch { case lit == "true": return true, true case lit == "false": return false, true } if n, err := strconv.ParseInt(lit, 10, 32); err == nil { return n, true } if strings.HasPrefix(lit, `"`) { if s, err := strconv.Unquote(lit); err == nil { return s, true } } return nil, false } // lineEnds are the characters a reader may take as a line's end: a setting holds none (novox/hq issue 339). const lineEnds = "\n\r\v\f\x00\u0085

" // jsQuoted is a string as a preference file writes it: quote, backslash and the control characters escaped the // way Gecko's preference parser reads them. func jsQuoted(s string) string { var b strings.Builder b.WriteByte('"') for _, r := range s { switch { case r == '"': b.WriteString(`\"`) case r == '\\': b.WriteString(`\\`) case r == '\n': b.WriteString(`\n`) case r == '\r': b.WriteString(`\r`) case r == '\t': b.WriteString(`\t`) case r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 || r == 0x85: fmt.Fprintf(&b, `\u%04x`, r) default: b.WriteRune(r) } } b.WriteByte('"') return b.String() } // declaredText is the head of the user.js the module places: its mark and the setting prefs, one statement a // line, by name. present says the setting is on this machine; err says why it is refused. func (m *Machine) declaredText() (string, bool, error) { prefs, present, err := m.readDeclaredPrefs() if err != nil || !present { return "", present, err } return composeDeclared(prefs), true, nil } // composeDeclared is the user.js head for a set of declared preferences. func composeDeclared(prefs map[string]string) string { var b strings.Builder b.WriteString(mark + ", novox/hq issue 345). Owned by the mesh: the tools write it into the profile from the\n" + "// module's settings at Betterbird's start (betterbird_user_js with apply, betterbird_restart, the launcher), and\n" + "// Betterbird reads it at its start, where it wins over what Betterbird saved. Change the settings, never this file.\n" + "//\n// The setting prefs (novox/hq issue 586): the operator's setup, mesh-wide with a node's own layer over it.\n") names := make([]string, 0, len(prefs)) for n := range prefs { names = append(names, n) } sort.Strings(names) for _, n := range names { fmt.Fprintf(&b, "user_pref(%s, %s);\n", quoted(n), prefs[n]) } if len(names) == 0 { b.WriteString("// none\n") } return b.String() } func sortedKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out }