package main // The configuration of Betterbird's preferences (novox/hq issue 345, issue 586), in three layers: // // - LOCKED: the enterprise policy (/opt/betterbird/distribution/policies.json). Its Preferences entries with // Status locked win over everything, and Betterbird's settings show them greyed out. // - DECLARED: the setting prefs, the operator's setup: mesh-wide, with a node's layer over it key by key. The // node-engine writes it to ~/.config/betterbird/prefs.json and the tools place it in the profile's user.js, // where it wins over prefs.js at every start. // - BETTERBIRD'S DEFAULT: what Betterbird starts with. The module's defaults file // (/opt/betterbird/defaults/pref/mesh-defaults.js) holds no value of the operator's any more (issue 586): it // names the preferences the module keeps at Betterbird's own default, where a value the profile saved is drift. // // betterbird_config shows each managed preference through the layers and flags the drift; betterbird_config_apply // clears a drifting value from prefs.js while Betterbird is not running; keep proposes a node layer of the setting // prefs that holds the profile's value for this node instead, through the controller's settings verb, never by // writing user.js. import ( "archive/zip" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) const ( // prefDir is the installation's own defaults directory; the module places its files there. prefDir = installDir + "/defaults/pref" // meshDefaults is the module's DEFAULT layer. meshDefaults = prefDir + "/mesh-defaults.js" // omniJa is Betterbird's packed application, holding its own default preferences. omniJa = installDir + "/omni.ja" ) // moduleFiles are the module's files in the installation's defaults directory: their values are the module's // defaults, not Betterbird's. var moduleFiles = map[string]bool{"mesh-defaults.js": true, "vendor-prefs.js": true} var ( // defaultLine is one pref(…) or sticky_pref(…) line of a defaults file; a comment may follow it. defaultLine = regexp.MustCompile(`^\s*(?:sticky_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;`) // keepsDefault is a line of the module's defaults naming a preference it manages without a value. keepsDefault = regexp.MustCompile(`^//\s*keeps Betterbird's default:\s*([A-Za-z0-9_.@{}\-]+)\s*$`) ) // builtIn are the defaults compiled into Betterbird of the preferences the module keeps at Betterbird's default, // which no defaults file gives: so a value saved equal to them is not drift. var builtIn = map[string]string{ themePref: `"` + defaultTheme + `"`, "browser.display.use_system_colors": "false", "layout.css.always_underline_links": "false", } const ( themePref = "extensions.activeThemeID" defaultTheme = "default-theme@mozilla.org" // themeRemedy is how the theme comes back: Betterbird keeps the active theme in its add-on database // (extensions.json), and the preference only mirrors it (XPIDatabase addonChanged), so clearing it changes // nothing and betterbird_config_apply never touches it. themeRemedy = "enable the default theme in Betterbird's add-on manager (Add-ons and Themes, Themes): the active " + "theme is kept in the add-on database, and its preference only mirrors it, so clearing prefs.js would not change it" ) // activeTheme is the theme the profile's add-on database marks active, or "" when it cannot be read. func activeTheme(profile string) string { raw, err := readBounded(filepath.Join(profile, "extensions.json")) if err != nil { return "" } var ext struct { Addons []struct { ID string `json:"id"` Type string `json:"type"` Active bool `json:"active"` } `json:"addons"` } if json.Unmarshal(raw, &ext) != nil { return "" } for _, a := range ext.Addons { if a.Type == "theme" && a.Active { return a.ID } } return "" } // unmanageable says why a name in the module's defaults is never managed by these tools, or "": a credential's, // an account's or a server's preference, which betterbird_config never answers and betterbird_config_apply never // clears, whatever the defaults file lists. func unmanageable(name string) string { if why := withheld(name); why != "" { return why } for _, p := range accountOwned { if strings.HasPrefix(name, p) { return "an account's preference, the setting accounts' alone" } } return "" } // answer is a value as these tools answer it: masked, but for the theme's id. func (m *Machine) answer(name, v string) string { if allowedExtensions[name] { return v } return m.maskValue(v) } // nodeName is the machine this runtime serves, as the mesh names it: a variable so a test answers. var nodeName = func() string { return os.Getenv("MESH_NODE") } // shipped is the module's DEFAULT layer as placed on the machine. type shipped struct { values map[string]string // keeps are the managed names whose module value is Betterbird's own default. keeps map[string]bool // order is every managed name, in the file's order. order []string } // parseShipped reads the module's defaults file. func parseShipped(raw []byte) shipped { s := shipped{values: map[string]string{}, keeps: map[string]bool{}} for _, line := range strings.Split(string(raw), "\n") { if mm := keepsDefault.FindStringSubmatch(strings.TrimSpace(line)); mm != nil { if !s.keeps[mm[1]] { s.keeps[mm[1]] = true s.order = append(s.order, mm[1]) } continue } if mm := defaultLine.FindStringSubmatch(line); mm != nil { if _, seen := s.values[mm[1]]; !seen { s.order = append(s.order, mm[1]) } s.values[mm[1]] = mm[2] } } return s } // shippedDefaults reads the module's defaults file from the installation. func (m *Machine) shippedDefaults() (shipped, error) { raw, err := readBounded(m.path(meshDefaults)) if err != nil { return shipped{values: map[string]string{}, keeps: map[string]bool{}}, err } return parseShipped(raw), nil } // DefaultValue is a default preference and the file that gives it. type DefaultValue struct { Value string From string // Module says the module's files give it, not Betterbird's. Module bool } // readDefaultsInto reads the pref(…) lines of the files of one place, in Gecko's order: it reads a place's // files last-first by name (Preferences.cpp, pref_ReadDefaultPrefs), so of a name in several files the value // of the file first by name holds. A later place overrides an earlier one. func readDefaultsInto(out map[string]DefaultValue, files map[string][]byte, label func(string) (string, bool)) { names := make([]string, 0, len(files)) for n := range files { names = append(names, n) } sort.Strings(names) place := map[string]DefaultValue{} for i := len(names) - 1; i >= 0; i-- { from, module := label(names[i]) for _, line := range strings.Split(string(files[names[i]]), "\n") { if mm := defaultLine.FindStringSubmatch(line); mm != nil { place[mm[1]] = DefaultValue{Value: mm[2], From: from, Module: module} } } } for k, v := range place { out[k] = v } } // mostDefaultsFile bounds one defaults file read from omni.ja. const mostDefaultsFile = 4 << 20 // defaults reads every default preference Betterbird starts with: greprefs.js and defaults/pref/*.js in // omni.ja, then the installation's defaults/pref/*.js, where the module's files are. A preference compiled into // Betterbird (a static preference) is in no file, and is absent here. withModule false leaves the module's own // files out, so what remains is Betterbird's. The error says what could not be read. func (m *Machine) defaults(withModule bool) (map[string]DefaultValue, error) { out := map[string]DefaultValue{} var problems []string if r, err := zip.OpenReader(m.path(omniJa)); err != nil { problems = append(problems, omniJa+" is unreadable: "+err.Error()) } else { gre, app := map[string][]byte{}, map[string][]byte{} for _, f := range r.File { switch { case f.Name == "greprefs.js": case strings.HasPrefix(f.Name, "defaults/pref/") && strings.HasSuffix(f.Name, ".js") && !strings.Contains(f.Name[len("defaults/pref/"):], "/"): default: continue } rc, err := f.Open() if err != nil { continue } b, _ := io.ReadAll(io.LimitReader(rc, mostDefaultsFile)) rc.Close() if f.Name == "greprefs.js" { gre[f.Name] = b } else { app[filepath.Base(f.Name)] = b } } r.Close() readDefaultsInto(out, gre, func(n string) (string, bool) { return "Betterbird's default (omni.ja " + n + ")", false }) readDefaultsInto(out, app, func(n string) (string, bool) { return "Betterbird's default (omni.ja defaults/pref/" + n + ")", false }) } installed := map[string][]byte{} entries, _ := os.ReadDir(m.path(prefDir)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") || (moduleFiles[e.Name()] && !withModule) { continue } if b, err := readBounded(m.path(filepath.Join(prefDir, e.Name()))); err == nil { installed[e.Name()] = b } } readDefaultsInto(out, installed, func(n string) (string, bool) { if moduleFiles[n] { return "the module's default (" + prefDir + "/" + n + ")", true } return "Betterbird's default (" + prefDir + "/" + n + ")", false }) if len(problems) > 0 { return out, errors.New(strings.Join(problems, "; ")) } return out, nil } // policyPrefs are the Preferences entries of the placed policy: name to its value as a preference file writes // it, and whether it is locked. An unreadable policy has none. type policyPref struct { Value string Locked bool } func policyPrefsOf(raw []byte) map[string]policyPref { var p struct { Policies struct { Preferences map[string]struct { Value any `json:"Value"` Status string `json:"Status"` } `json:"Preferences"` } `json:"policies"` } out := map[string]policyPref{} if json.Unmarshal(raw, &p) != nil { return out } for name, e := range p.Policies.Preferences { out[name] = policyPref{Value: literal(e.Value), Locked: e.Status == "locked"} } return out } // locks are the preferences the placed policy locks: name to value. func (m *Machine) locks() map[string]string { raw, err := readBounded(m.path(policyFile)) if err != nil { return map[string]string{} } out := map[string]string{} for name, p := range policyPrefsOf(raw) { if p.Locked { out[name] = p.Value } } return out } // literal is a JSON value as a preference file writes it. func literal(v any) string { switch x := v.(type) { case bool: return strconv.FormatBool(x) case float64: return strconv.FormatFloat(x, 'f', -1, 64) case string: return quoted(x) case nil: return "" } b, _ := json.Marshal(v) return string(b) } // statementsOf are the user_pref(…) statements of a user.js, several to a line allowed: name to value. func statementsOf(raw []byte) map[string]string { out := map[string]string{} for _, line := range strings.Split(string(raw), "\n") { for _, st := range splitStatements(strings.TrimSpace(line)) { if mm := prefLine.FindStringSubmatch(st); mm != nil { out[mm[1]] = mm[2] } } } return out } // ConfigPref is one managed preference through the layers. type ConfigPref struct { Name string `json:"name"` // Layer is locked (the policy), declared (the setting prefs), default (a value of the module's defaults file) or // "Betterbird's default" (a managed preference whose module value is Betterbird's own). Layer string `json:"layer"` Locked string `json:"locked,omitempty"` ModuleDefault string `json:"module_default,omitempty"` BetterbirdDefault string `json:"betterbird_default,omitempty"` // Declared is what the setting prefs says on this machine: mesh-wide, with the node's layer over it. Declared string `json:"declared,omitempty"` // Profile is what the profile saved in its prefs.js. Profile string `json:"profile,omitempty"` Effective string `json:"effective"` From string `json:"effective_from"` // Drift says how the profile's own value beats the module's without a node layer saying so. Drift string `json:"drift,omitempty"` // Remedy is how drift that clearing prefs.js cannot fix is fixed (the theme). Remedy string `json:"remedy,omitempty"` // module is the value the module means: its default, or Betterbird's. module string } // ConfigAnswer is what betterbird_config answers. type ConfigAnswer struct { Profile string `json:"profile"` Defaults string `json:"module_defaults"` Prefs []ConfigPref `json:"prefs"` // Drifting are the names that drift. Drifting []string `json:"drifting"` // Refused are names the module's defaults list that the tools never manage: an account's or a credential's. Refused []string `json:"refused,omitempty"` // PrefsProblem says why the setting prefs on this machine is refused. PrefsProblem string `json:"prefs_problem,omitempty"` // Proposed is the controller's answer to a node layer proposed with keep. Proposed string `json:"proposed,omitempty"` Note string `json:"note,omitempty"` } // config reads every managed preference through the layers: the policy's locks, the module's defaults, the // node layer (the declared user.js), the profile's prefs.js and user.js. func (m *Machine) config() (ConfigAnswer, Profile, error) { a := ConfigAnswer{Prefs: []ConfigPref{}, Drifting: []string{}} p, err := m.profile() if err != nil { return a, p, err } a.Profile = p.Dir sh, serr := m.shippedDefaults() if serr != nil { a.Defaults = "not placed: " + meshDefaults + " is missing; the module's push places it" } else { a.Defaults = fmt.Sprintf("placed: %s, %d preference(s) with a value and %d kept at Betterbird's default", meshDefaults, len(sh.values), len(sh.keeps)) } locked := m.locks() own, _ := m.defaults(false) node, _, nerr := m.readDeclaredPrefs() if nerr != nil { a.PrefsProblem = nerr.Error() } prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js")) saved := readPrefs(prefsRaw) userRaw, _ := readBounded(filepath.Join(p.path, "user.js")) placed := statementsOf(userRaw) var names []string for n := range locked { names = append(names, n) } sort.Strings(names) listed := map[string]bool{} for _, n := range names { listed[n] = true } for _, n := range append(append([]string{}, sh.order...), sortedKeys(node)...) { if !listed[n] { listed[n] = true names = append(names, n) } } theme := activeTheme(p.path) for _, n := range names { if why := unmanageable(n); why != "" { a.Refused = append(a.Refused, n+": "+why) continue } c := ConfigPref{Name: n, Declared: node[n], Profile: saved[n]} if n == themePref && theme != "" { // The add-on database says which theme is active; the preference only mirrors it. c.Profile = quoted(theme) } bb, hasBB := own[n] if hasBB { c.BetterbirdDefault = bb.Value } else if v, ok := builtIn[n]; ok { c.BetterbirdDefault = v bb.From = "Betterbird's built-in default" } switch v, isLocked := locked[n]; { case isLocked: c.Layer, c.Locked, c.module = "locked", v, v case c.Declared != "": c.Layer, c.module = "declared", c.Declared case sh.keeps[n]: c.Layer = "Betterbird's default" c.module = c.BetterbirdDefault default: c.Layer, c.ModuleDefault, c.module = "default", sh.values[n], sh.values[n] } switch { case c.Locked != "": c.Effective, c.From = c.Locked, "locked by the module's policy" case placed[n] != "": c.Effective, c.From = placed[n], "the profile's user.js (wins at the start)" case c.Profile != "": c.Effective, c.From = c.Profile, "the profile's prefs.js" case c.ModuleDefault != "": c.Effective, c.From = c.ModuleDefault, "the module's default" case c.BetterbirdDefault != "": c.Effective, c.From = c.BetterbirdDefault, bb.From default: c.From = "Betterbird's built-in default (in no defaults file)" } if why := drift(c, placed[n]); why != "" { c.Drift = why switch { case n == themePref: c.Remedy = themeRemedy case c.Layer == "declared": c.Remedy = "betterbird_user_js with apply places the setting in the profile's user.js; Betterbird reads it " + "at its next start (betterbird_restart)" } a.Drifting = append(a.Drifting, n) } c.Declared, c.Profile, c.Effective = m.answer(n, c.Declared), m.answer(n, c.Profile), m.answer(n, c.Effective) a.Prefs = append(a.Prefs, c) } return a, p, nil } // drift says how the profile differs from the module's configuration, or "": for a declared preference, the // profile's user.js not holding the declared value (so Betterbird does not start with it); for a preference kept at // Betterbird's default, a value in prefs.js that is not it. A lock holds whatever the profile says. func drift(c ConfigPref, placed string) string { if c.Locked != "" { return "" } if c.Declared != "" { if placed == c.Declared { return "" } return fmt.Sprintf("the setting prefs says %s, and the profile's user.js does not hold it yet", c.Declared) } if c.Profile == "" || c.Profile == c.module { return "" } want := c.module if want == "" { want = "Betterbird's built-in default" } where := "the profile's prefs.js holds" if c.Name == themePref { where = "the profile's active theme is" } return fmt.Sprintf("%s %s, which beats the module's %s, and the setting prefs does not say so", where, c.Profile, want) } // Config is what betterbird_config answers; with keep it proposes a node layer of the setting prefs that holds // the profile's own values of the named managed preferences on this machine (novox/hq issue 345). The module // never writes user.js for it: the operator approves the layer, and the next push writes it. func (m *Machine) Config(keep []string) (ConfigAnswer, error) { a, p, err := m.config() if err != nil { return a, err } a.Note = "drift is a declared value the profile's user.js does not hold yet (betterbird_user_js with apply), or a " + "value the profile saved where the module keeps Betterbird's default: betterbird_config_apply clears that one " + "(Betterbird quit), or keep proposes a node layer of the setting prefs that holds it for this machine" if len(keep) == 0 { return a, nil } by := map[string]ConfigPref{} for _, c := range a.Prefs { by[c.Name] = c } prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js")) saved := readPrefs(prefsRaw) kept := map[string]string{} for _, n := range keep { c, managed := by[n] switch { case !managed: return a, fmt.Errorf("nothing was proposed: %s is not a preference the module manages (betterbird_config lists them)", n) case c.Locked != "": return a, fmt.Errorf("nothing was proposed: %s is locked by the module's policy; a lock changes only in the catalogue", n) case n == themePref: return a, fmt.Errorf("nothing was proposed: a node layer cannot choose the theme; the add-on manager does (%s)", themeRemedy) case saved[n] == "": return a, fmt.Errorf("nothing was proposed: the profile saved no value of its own for %s", n) } kept[n] = saved[n] } node := nodeName() if node == "" { return a, errors.New("nothing was proposed: this runtime does not say which machine it serves (MESH_NODE)") } // A layer is replaced whole, so the proposal carries this node's layer as it stands, with the kept values in // the setting prefs beside what it held (novox/hq issue 586: one entry per preference). layer, err := readLayer(node) if err != nil { return a, fmt.Errorf("nothing was proposed: %v", err) } prefs, _ := layer["prefs"].(map[string]any) if prefs == nil { prefs = map[string]any{} } for _, n := range keep { v, ok := prefValue(kept[n]) if !ok { return a, fmt.Errorf("nothing was proposed: the profile's value of %s is not one the setting can hold", n) } prefs[n] = v } layer["prefs"] = prefs said, err := proposeLayer(node, layer, false) a.Proposed = m.maskValue(said) if err != nil { return a, err } a.Note = "proposed for " + node + ": the operator approves it on a channel that proves who answers; the next " + "push writes it, and it is placed at Betterbird's next start from the launcher (or betterbird_restart). " + "The layer carries this node's layer as it stands, with the kept preferences in the setting prefs" return a, nil } // Cleared is one value betterbird_config_apply took out of prefs.js. type Cleared struct { Name string `json:"name"` Was string `json:"was"` // Now is the value that holds once Betterbird starts again. Now string `json:"now"` } // ConfigApplyAnswer is what betterbird_config_apply answers. type ConfigApplyAnswer struct { Profile string `json:"profile"` Applied bool `json:"applied"` Clears []Cleared `json:"clears"` // NotCleared is drift that clearing prefs.js cannot fix, each with how it is fixed. NotCleared []string `json:"not_cleared,omitempty"` Note string `json:"note"` } // ConfigApply takes the drifting values out of the profile's prefs.js, so the module's value holds at the next // start. Without apply it lists them. It acts only while Betterbird is not running, because Betterbird // rewrites prefs.js when it quits. Every other line of prefs.js is kept as it was. func (m *Machine) ConfigApply(apply bool) (ConfigApplyAnswer, error) { c, p, err := m.config() a := ConfigApplyAnswer{Profile: c.Profile, Clears: []Cleared{}} if err != nil { return a, err } if strings.HasPrefix(c.Defaults, "not placed") { return a, fmt.Errorf("the module's defaults are %s: nothing to bring the profile back to", c.Defaults) } drifting := map[string]bool{} for _, cp := range c.Prefs { if cp.Drift != "" && cp.Remedy != "" { a.NotCleared = append(a.NotCleared, cp.Name+": "+cp.Remedy) continue } if cp.Drift != "" { drifting[cp.Name] = true now := m.answer(cp.Name, cp.module) if now == "" { now = "Betterbird's built-in default" } a.Clears = append(a.Clears, Cleared{Name: cp.Name, Was: cp.Profile, Now: now}) } } if !apply { a.Note = "a dry run: these values would be taken out of prefs.js. Quit Betterbird, then call with apply" return a, nil } if ps := m.mainProcs(); len(ps) > 0 { return a, fmt.Errorf("Betterbird is running (pid %d): it rewrites prefs.js when it quits, so nothing was cleared; quit it, then apply again", ps[0].PID) } // The profile's own lock is Gecko's sign that a Betterbird holds it, whichever installation started it. if _, err := os.Lstat(filepath.Join(p.path, "lock")); err == nil { return a, errors.New("the profile is locked (its lock file is there): a Betterbird holds it, or one ended " + "without quitting; nothing was cleared. Quit Betterbird (or start and quit it once), then apply again") } if len(drifting) == 0 { a.Applied, a.Note = true, "nothing in prefs.js drifts: it was left as it was" return a, nil } target := filepath.Join(p.path, "prefs.js") raw, err := readBounded(target) if err != nil { return a, err } st, err := os.Stat(target) if err != nil { return a, err } lines := strings.SplitAfter(string(raw), "\n") var kept strings.Builder for _, l := range lines { if mm := prefLine.FindStringSubmatch(strings.TrimRight(l, "\n")); mm != nil && drifting[mm[1]] { continue } kept.WriteString(l) } tmp := target + ".mesh-new" if err := writeSynced(tmp, []byte(kept.String()), st.Mode().Perm()); err != nil { os.Remove(tmp) return a, err } if err := os.Rename(tmp, target); err != nil { os.Remove(tmp) return a, err } a.Applied = true a.Note = fmt.Sprintf("cleared %d value(s) from prefs.js; the module's values hold from Betterbird's next start", len(a.Clears)) return a, nil } // writeSynced writes a file and syncs it to disk before it is renamed into place, so a crash never leaves an // empty prefs.js. func writeSynced(path string, data []byte, mode os.FileMode) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return err } if _, err := f.Write(data); err != nil { f.Close() return err } if err := f.Sync(); err != nil { f.Close() return err } return f.Close() }