betterbird: never lose an unsent message, never answer an address, and keep the test apart (review of #155)
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

A restart ended Betterbird with whatever a composer held; now it refuses while a Msgcompose window is
open unless discard_unsent says the operator agreed, and answers within the runtime's 30 s. A
preference's value could carry an address, a user%40domain@host URI, a host or the home; those are
masked, recent-folder, account and cloud-file preferences and every add-on preference but the theme's
id are withheld, and a request for everything is refused. A log kept the token after Bearer or Basic.
The reminder test's Betterbird counted as the operator's and left its profile behind when it ended on
its own; it is now left out everywhere and its unit removes the profile when it stops.
This commit is contained in:
2026-10-09 12:22:47 +02:00
parent a7953b969d
commit 3629089ced
6 changed files with 247 additions and 27 deletions
@@ -56,7 +56,8 @@ var readLink = os.Readlink
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(mainComm) {
if !strings.Contains(p.Command, "-contentproc") {
// The reminder test's Betterbird is never the operator's: its command line names its own profile.
if !strings.Contains(p.Command, "-contentproc") && !strings.Contains(p.Command, testUnit) {
out = append(out, p)
}
}
@@ -255,11 +256,17 @@ var (
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
"services.sync.", "mail.displayname", "mail.default_"}
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
// hold anything, and a theme's id is what a window's look depends on.
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
)
// withheld says why a preference's value is never answered, or "" when it may be.
func withheld(name string) string {
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
return "an add-on's own preference: only the theme's id (extensions.activeThemeID) is answered"
}
if passwordSecret.MatchString(name) {
return "its name says it can hold a credential"
}
@@ -288,6 +295,11 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
if len(names) == 0 {
return nil, errors.New("names is required: preferences by name, or a prefix ending in . or *, e.g. calendar.alarms.")
}
for _, n := range names {
if strings.Trim(strings.TrimSpace(n), ".*") == "" {
return nil, fmt.Errorf("%q asks for every preference: name one, or a prefix such as calendar.alarms.", n)
}
}
p, err := m.profile()
if err != nil {
return nil, err
@@ -339,8 +351,10 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
pr.From = from
if why := withheld(n); why != "" {
pr.Withheld = why
} else {
} else if allowedExtensions[n] {
pr.Value = v
} else {
pr.Value = m.maskValue(v)
}
out = append(out, pr)
}
@@ -581,11 +595,31 @@ var (
credentialWord = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|authorization|cookie)\S*\s*[=:]\s*)\S+`)
)
// userAtHost and hostName are what a preference's value may name a server by.
var (
userAtHost = regexp.MustCompile(`[A-Za-z0-9._%+\-]+%40[A-Za-z0-9.\-]+@[A-Za-z0-9.\-]+`)
// A host is a name of three labels or more, or of two whose last is a common top-level domain: a
// file's name (a.pdf) is not one.
hostName = regexp.MustCompile(`(?i)\b(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.){2,}[a-z]{2,}|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|org|net|be|nl|de|fr|eu|io|uk|us|info|biz|dev|app|cloud|local|lan|internal|home|corp))\b`)
bearerOrBasic = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]+`)
)
func mask(s string) string {
s = userAtHost.ReplaceAllString(s, "<hidden>")
s = emailAddress.ReplaceAllString(s, "<hidden>")
s = bearerOrBasic.ReplaceAllString(s, "$1 <hidden>")
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
}
// maskValue is a preference's value as answered: the home as ~, and no address, user or host.
func (m *Machine) maskValue(v string) string {
if m.Home != "" && m.Home != "/" {
v = strings.ReplaceAll(v, strings.TrimSuffix(m.Home, "/")+"/", "~/")
}
v = mask(v)
return hostName.ReplaceAllString(v, "<host>")
}
// mostAnswer bounds a log answer.
const mostAnswer = 128 << 10
@@ -631,24 +665,49 @@ func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error)
// RestartAnswer is what betterbird_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
UserJS string `json:"user_js"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
// Composers are the message windows that were open: what was unsent and is gone.
Composers int `json:"composers_discarded,omitempty"`
Note string `json:"note,omitempty"`
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
UserJS string `json:"user_js"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends Betterbird (asked to quit, forced after 20 s), places the module's user.js when it can,
// composerClass is the X instance of a message being written.
var composerClass = regexp.MustCompile(`\("Msgcompose" "eu\.betterbird\.Betterbird"\)`)
// composers counts the open message windows, from the X window tree.
func (m *Machine) composers(s Session) (int, error) {
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
return 0, err
}
return len(composerClass.FindAllString(o.Stdout, -1)), nil
}
// Restart ends Betterbird (asked to quit, forced after 10 s), places the module's user.js when it can,
// and starts Betterbird again in the operator's session under the account's service manager, its output
// in the journal.
func (m *Machine) Restart() (RestartAnswer, error) {
// in the journal. While a message is being written it refuses, unless discard says the unsent may go.
// It answers within the runtime's 30 s: a start that has not shown after 4 s is said, and
// betterbird_status sees it come.
func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stopProcs(20*time.Second, m.mainProcs())
n, err := m.composers(s)
switch {
case err != nil && !discard:
return a, fmt.Errorf("whether a message is being written cannot be told (%v): restart with discard_unsent only if none is", err)
case n > 0 && !discard:
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
}
a.Composers = n
a.Ended, a.Killed = m.stopProcs(10*time.Second, m.mainProcs())
if u, err := m.userJS(true); err != nil {
a.UserJS = "not placed: " + err.Error()
} else {
@@ -657,13 +716,15 @@ func (m *Machine) Restart() (RestartAnswer, error) {
if err := m.detach(s, restartAs, startBin); err != nil {
return a, err
}
m.waitFor(mainComm, 8*time.Second)
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
m.Sleep(250 * time.Millisecond)
}
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
return a, fmt.Errorf("Betterbird was started as %s but no main process appeared within 8 s: betterbird_log says why", a.Unit)
a.Note = "started as " + a.Unit + "; no main process yet after 4 s: betterbird_status shows it once it runs, betterbird_log says why if it does not"
}
return a, nil
}