From ae8ab366a8efc9817c1a76633d1d36fe0742f13a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:00:36 +0200 Subject: [PATCH] Remove the betterbird module: the operator unassigned it from g14 and shanks (issue 806, ADR 0379) --- checks/config/main.go | 2 +- modules/betterbird/README.md | 360 ------ .../cmd/betterbird-tools/accounts.go | 842 ------------- .../cmd/betterbird-tools/accounts_test.go | 567 --------- .../cmd/betterbird-tools/adoption.go | 331 ----- .../betterbird/cmd/betterbird-tools/args.go | 97 -- .../cmd/betterbird-tools/betterbird.go | 1066 ----------------- .../cmd/betterbird-tools/betterbird_test.go | 276 ----- .../betterbird/cmd/betterbird-tools/config.go | 666 ---------- .../cmd/betterbird-tools/config_test.go | 541 --------- .../cmd/betterbird-tools/configuration.go | 472 -------- .../betterbird-tools/configuration_test.go | 518 -------- .../cmd/betterbird-tools/copies_test.go | 37 - .../betterbird/cmd/betterbird-tools/derive.go | 700 ----------- .../cmd/betterbird-tools/derive_test.go | 263 ---- .../cmd/betterbird-tools/desktop.go | 601 ---------- .../cmd/betterbird-tools/desktop_test.go | 219 ---- .../cmd/betterbird-tools/filters.go | 271 ----- .../cmd/betterbird-tools/install.go | 307 ----- .../cmd/betterbird-tools/install_test.go | 231 ---- .../cmd/betterbird-tools/listing.go | 480 -------- .../betterbird/cmd/betterbird-tools/main.go | 339 ------ .../cmd/betterbird-tools/manifest_test.go | 324 ----- .../betterbird/cmd/betterbird-tools/owned.go | 749 ------------ .../betterbird/cmd/betterbird-tools/prefs.go | 228 ---- .../cmd/betterbird-tools/profilekinds.go | 178 --- .../cmd/betterbird-tools/reminder.go | 199 --- .../cmd/betterbird-tools/review_test.go | 182 --- .../cmd/betterbird-tools/setup_test.go | 16 - .../cmd/betterbird-tools/templates_test.go | 64 - modules/betterbird/files/accounts.json | 3 - modules/betterbird/files/betterbird | 10 - modules/betterbird/files/configuration.json | 6 - .../files/eu.betterbird.Betterbird.desktop | 25 - modules/betterbird/files/prefs.json | 3 - modules/betterbird/files/tags.json | 3 - modules/betterbird/go.mod | 5 - modules/betterbird/go.sum | 2 - .../install/defaults-pref/mesh-defaults.js | 20 - .../install/defaults-pref/vendor-prefs.js | 3 - .../install/distribution/policies.json | 28 - modules/betterbird/module.json | 253 ---- modules/mesh-issues/files/settings.json | 1 - modules/mesh-issues/module.json | 1 - 44 files changed, 1 insertion(+), 11488 deletions(-) delete mode 100644 modules/betterbird/README.md delete mode 100644 modules/betterbird/cmd/betterbird-tools/accounts.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/accounts_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/adoption.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/args.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/betterbird.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/betterbird_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/config.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/config_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/configuration.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/configuration_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/copies_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/derive.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/derive_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/desktop.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/desktop_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/filters.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/install.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/install_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/listing.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/main.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/manifest_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/owned.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/prefs.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/profilekinds.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/reminder.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/review_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/setup_test.go delete mode 100644 modules/betterbird/cmd/betterbird-tools/templates_test.go delete mode 100644 modules/betterbird/files/accounts.json delete mode 100755 modules/betterbird/files/betterbird delete mode 100644 modules/betterbird/files/configuration.json delete mode 100644 modules/betterbird/files/eu.betterbird.Betterbird.desktop delete mode 100644 modules/betterbird/files/prefs.json delete mode 100644 modules/betterbird/files/tags.json delete mode 100644 modules/betterbird/go.mod delete mode 100644 modules/betterbird/go.sum delete mode 100644 modules/betterbird/install/defaults-pref/mesh-defaults.js delete mode 100644 modules/betterbird/install/defaults-pref/vendor-prefs.js delete mode 100644 modules/betterbird/install/distribution/policies.json delete mode 100644 modules/betterbird/module.json diff --git a/checks/config/main.go b/checks/config/main.go index d332c4e9..8e1faf76 100644 --- a/checks/config/main.go +++ b/checks/config/main.go @@ -110,7 +110,7 @@ func Check(root string) (map[string][]string, error) { // The modules taking a whole layer were found after the list was first written and before the trunk held a copy // (issue 735), so they stand in it too. var seed = map[string]bool{ - "betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true, + "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true, "baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true, "icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true, } diff --git a/modules/betterbird/README.md b/modules/betterbird/README.md deleted file mode 100644 index bbc081d1..00000000 --- a/modules/betterbird/README.md +++ /dev/null @@ -1,360 +0,0 @@ -# betterbird - -Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's -Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316, -ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held -on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through -Betterbird's enterprise policy, and owns Betterbird's whole configuration as settings: the preferences, the -mail accounts without their credentials, the identities with their signatures, the CalDAV calendars, the -CardDAV address books, the filters and the tags. - -It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a -transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that -was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq -issue 345. - -## Owns - -| what | where | class | -|---|---|---| -| Betterbird, the upstream release (`archive`, pinned by URL and sha256) | `/opt/betterbird` | owned, whole | -| the enterprise policy (`install/distribution/policies.json`) | `/opt/betterbird/distribution` | owned, whole | -| Owl's xpi (`owl`, pinned by the vendor's sha256) | `/opt/betterbird/owl` | owned, whole | -| the module's defaults (`install/defaults-pref/mesh-defaults.js`: the preferences kept at Betterbird's own default, no value of the operator's) and the spelling dictionaries' default (`vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its two files | -| the tools' program, for the start's preparation | `/usr/local/lib/mesh-betterbird` | owned, whole | -| the start | `/usr/local/bin/betterbird` | owned, whole | -| the launcher entry | `/usr/local/share/applications/eu.betterbird.Betterbird.desktop` | owned, whole | -| the module's directory, with the settings as the node-engine writes them (`prefs.json`, `accounts.json`, `configuration.json`, `tags.json`), the records of what was adopted or created, and the filter files it replaced (`kept/`) | `~/.config/betterbird/` | owned, whole | -| the profile's `user.js`, placed by the tools | the profile | the module's, or never touched | -| the lists in the profile's `prefs.js` (accounts, outgoing servers, an adopted account's identities, the calendars' order), and the preferences of what the module created and the settings no longer hold | the profile | written into: the module's keys only, only while Betterbird is closed | -| each declared account's `msgFilterRules.dat` (setting `filters`) | the account's directory in the profile | owned, whole, only while Betterbird is closed | -| the profile's own values of the preferences the module manages, in `prefs.js` | the profile | cleared only by `betterbird_config_apply` with apply, Betterbird quit | -| the profile: mail, address books, calendars, logins | `~/.thunderbird/` | found (ADR 0182) | - -**The community package `betterbird-bin` is declared absent** (ADR 0180), before anything is placed in -`/opt/betterbird`: the node-engine removes it with `pacman -R`, then unpacks the release where the package -had it. Betterbird chooses its profile by its installation's path, so the same profile opens after the -switch. A package left in place (its removal failed) holds the path, and the release is refused there by -the node-engine rather than mixed in. The module creates no link: the start, a launcher entry naming the -icon in the installation, and the dictionaries as a default preference replace the package's links. - -**Every new build waits for the operator** (`upgrade: record`): the first push to a node removes the -package and replaces what a running Betterbird has open, so the operator quits Betterbird, then pushes. -An upgrade is a change to the pins, reviewed and delivered like any other (ADR 0316 §5). - -**The pins.** The release is `betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz` (81,620,684 bytes) and -Owl is 1.5.6.1 (430,201 bytes), each pinned by sha256 in `module.json`. The build seat fetches each once, -refuses it when the digest differs, keeps it under its sha256, and packs it into the artifact store; a node -receives the mesh's archive by digest and never asks the vendor. **Owl is never in this repository**: its -licence forbids copying it to third parties, and its vendor serves only its newest version, at one -address. Keeping the pinned copy in the mesh's private artifact store for the operator's own machines was -the operator's call (2026-10-12, ADR 0316 §6). - -**Owl's range.** Owl declares which Betterbird versions it supports (`strict_min_version` to -`strict_max_version`: 115 to 156 for 1.5.6.1). `owlSupports` in `manifest_test.go` holds the range read -from each pinned xpi, and the module's tests refuse a Betterbird pin outside it: moving Owl's pin means -reading the new range there (ADR 0316 §8). - -**The policy** turns Betterbird's own updater off (`DisableAppUpdate`, which Betterbird's update check -honours) and installs Owl into every profile from `file:///opt/betterbird/owl/owl.xpi` as -`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy -installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a -fact by `betterbird_status`; Owl installed by policy cannot be removed in Betterbird, so the remedy is -moving the pin. An older one is a finding of `betterbird_check`, with the same remedy. - -**Betterbird never asks to be the default client.** The policy locks `mail.shell.checkDefaultClient` off -(`Preferences`, `Status: locked`): Betterbird's own default (`all-thunderbird.js`) is to ask at every start, -with a window to make it the default for mail, news, feeds and calendar files, while that preference is true -(`messenger.js`). Betterbird 153 has no policy of its own for it (its `policies-schema.json`), and its -`Preferences` policy accepts the `mail.` prefix. A lock, not a default: the window's own "always check" box -writes the preference into the profile, which wins over a default; a lock wins over the profile and greys the -box out in the settings. `betterbird_check` says when Betterbird would ask (novox/hq issue 345). **Owl's licence stays the person's**: the mesh holds no licence, key or payment (ADR 0316 §9). - -**No start of its own.** Betterbird is started by the operator from the launcher. The start -(`/usr/local/bin/betterbird`) first runs `betterbird-tools prepare`, which, with no Betterbird of the -account running, places the user.js and writes what Betterbird rewrites itself (the lists, the removals, the -filter files); then it -starts Betterbird either way. `betterbird_restart` starts it as the transient user unit -`mesh-betterbird`, so its output then goes to the journal. - - -## The preferences: locks, the setting prefs, and Betterbird's own defaults - -Betterbird's preferences come in three layers (novox/hq issue 345, issue 586). **The operator's own choices -live in the module's configuration, never in the catalogue**: the module ships only its policy's locks and the -names it keeps at Betterbird's own default. - -| layer | where | who changes it | holds | -|---|---|---|---| -| **locked** | `/opt/betterbird/distribution/policies.json` (`install/distribution/`) | only a reviewed catalogue change | what must never differ on a node; Betterbird's settings show it greyed out | -| **declared** | the setting `prefs`, mesh-wide with a node's layer over it, written to `~/.config/betterbird/prefs.json` and placed in the profile's `user.js` | the operator, through the settings layers (trusted: the terminal or the operator's warrant) | the operator's setup, and a node's exceptions, such as a font size for a larger screen | -| **Betterbird's default** | Betterbird's own defaults; `mesh-defaults.js` names those the module manages there | a catalogue change | the preferences kept at Betterbird's default | - -A locked value wins over everything; a declared one (in `user.js`) wins over the profile's `prefs.js` at every -start; `prefs.js` wins over a default. - -**The setting `prefs` is one entry per preference** (issue 586): `{"prefs": {"mail.uifontsize": 14, …}}`, the -value true, false, a whole number or a string. The node-engine merges the layers key by key, so a node's layer -overrides one preference, or takes one out with `null`. The operator's setup approved on 2026-10-12 (compact -density, 14-point text, the vertical view with multi-line cards and threads, no new-mail pop-up, sound or tray -icon, the calendar's day from 09:00 to 20:00, reminders missed while asleep shown, chat off, remote content -blocked, Do Not Track; 21 preferences) moved from the catalogue's defaults file into the mesh-wide layer of -`prefs`, set on the operator's Approve. - -**The order that leaves no node on Betterbird's own defaults** (issue 586): merge this build (the `record` -upgrade policy holds it back from the nodes); propose the mesh-wide layer through the controller's `settings` -verb with `propose`, carrying the current `accounts` verbatim and `prefs` with the 21 values; the operator -approves; only then push the desktop nodes. Caveat: between that approval and a node's push, any other send to a -desktop node writes the map's text into the old build's user.js, and `betterbird_check` there says the user.js is -refused until the push. - -**Locked** (the policy): Betterbird's updater off (`DisableAppUpdate`); telemetry off (`DisableTelemetry`); -add-ons change only through the mesh's pins (`ExtensionUpdate` false); and three preferences locked off: -`mail.shell.checkDefaultClient` (the ask to be the default client), `mail.inappnotifications.enabled` (the -vendor's in-app notices), and `mailnews.start_page.enabled` (the start page, which otherwise loads from the -vendor at every start). - -**Kept at Betterbird's default** (`// keeps Betterbird's default: ` in `mesh-defaults.js`): the theme, -system colours, always-underlined links, manual spam marking, and the composer's attachment pane. A value the -profile saved for one of them is drift. - -**Drift.** `betterbird_config` shows each managed preference (locked, declared, kept) through the layers, and -flags **drift**: a declared value the profile's `user.js` does not hold yet (remedy: `betterbird_user_js` with -apply, then `betterbird_restart`), or a value the profile saved where the module keeps Betterbird's default. -`betterbird_check` reports it. - -- `betterbird_config_apply` with `apply` takes a kept preference's saved value out of `prefs.js`, so Betterbird's - default holds at the next start. It acts only while Betterbird is quit (no main process, and no `lock` in the - profile), and says what it cleared. -- **The theme is the exception.** Betterbird keeps the active theme in its add-on database (`extensions.json`), - and `extensions.activeThemeID` only mirrors it. Another theme is drift whose remedy is to enable the default - theme in the add-on manager. `betterbird_config_apply` never clears it, and `keep` refuses it. -- `betterbird_config` with `keep` proposes this node's layer of `prefs` holding the profile's values of the named - preferences beside what the layer holds, through the controller's settings verb, for the operator's Approve. - -**One owner per value.** `prefs` refuses a preference whose name can hold a credential, an account's -(`mail.account.`, `mail.identity.`, `mail.server.`, `mail.smtpserver.` and the account lists, ADR 0316 §11), and -those the settings below own: `calendar.registry.`, `calendar.list.`, `ldap_2.servers.` and `mailnews.tags.` -(ADR 0329 §6). - -## The user.js - -Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the one it saved -in `prefs.js`. The profile's directory has a random name per machine (`.default-default`), which a -manifest cannot name. So the tools compose the user.js from the settings — `prefs`, then the declared accounts, -then the declared identities, calendars, address books and tags under the keys each resolved to — and place it -in the profile: at the start from the launcher, `betterbird_user_js` with `apply`, or `betterbird_restart`. - -**The profile is found** as the one the running Betterbird holds open; with none running, as the -installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`. - -**A user.js the module did not write is never overwritten.** The module's begins with the line -`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with what to do. - -## The accounts: declared without credentials (ADR 0316 §10–§16, ADR 0317) - -The setting `accounts` reaches the machine as `~/.config/betterbird/accounts.json`, a trusted mergeable -file: a map of accounts by name, each with `address`, `from-name` and `label` (optional), `incoming` (`kind` -imap or pop3, `host`, `port`, `security` tls, starttls or none, `sign-in` password, encrypted-password, -oauth, kerberos, ntlm or tls-certificate, `user`) and `outgoing` (the same, without a kind; its sign-in may -be none), and, only in a node's own layer, `key`. **Never a password or a token**: the whole file is -refused when any key in it is shaped like one, and an account signs in at its first use, in Betterbird's -own window, where Betterbird keeps the result in its login store. The work account through Owl is never -declared: the person creates it in Owl's setup (ADR 0316 §13). - -At every apply (the start from the launcher, `betterbird_user_js` with apply, `betterbird_restart`), each -declared account, in its place in the setting, is (ADR 0317 §1): - -1. **kept by its recorded keys**, when the module recorded it on this node for this profile; -2. **its creation**, when it is under the module's own keys (`account_mesh_`, `id_mesh_`, - `server_mesh_`, `smtp_mesh_`); -3. **adopted by the key a node's layer names** (`key`), when that account is in the list, its default - identity has the address and it is not Owl's; -4. **adopted by its address**, when exactly one account's default identity has it: its keys (account, - default identity, incoming server, and the outgoing server that identity names) become the module's, and - the account lists are left as they are; -5. **created** under the module's own keys when no account has it: its keys are added at the end of - `mail.accountmanager.accounts` and `mail.smtpservers` in `prefs.js`, every other key kept in its place, - only while Betterbird is not running. - -**Refused, for that account alone and with the remedy** (§7): two accounts with the address (remove the -duplicate in Betterbird, or name the `key`), Owl's account at the address, a named key that does not -qualify, an account of another kind than declared (never converted), an identity that names no outgoing -server, and an outgoing server that another identity, neither adopted nor the module's, also uses -(Betterbird lets identities share one, and the declared values would change that other account's sending; -ADR 0317 §2 leaves the identity's server and the lists as they are, so the module refuses rather than give -it a server of its own). A recorded adoption follows the identity's current outgoing server at every apply, -under the same rule. The other accounts go ahead; the start and `betterbird_check` say it. -`betterbird_user_js` answers an account's preferences by name with their values withheld. - -The declared fields are written in the user.js under the account's keys, so they win at every start; only -the fields the setting names, never the account's directory, folders, filters, caches, mail or kept -sign-in, so an adopted account keeps its sign-in (§5). The apply names, by preference name only, each one -whose value it changed (§4). What was adopted or created is recorded in -`~/.config/betterbird/accounts-record.json`. **Leaving the setting** releases an adopted account (its -lines and its record go, the account stays the person's) and takes a created account's keys out of the -lists (its mail stays in the profile). - -**The value is derived, not retyped** (§9): `betterbird_accounts` with `propose` builds `accounts` from the -profile (every account but Owl's, no credential) and proposes it in the mesh-wide layer as it stands, through -the controller's settings verb; the operator approves on a channel that proves who answers. Its answer shows -the value it proposed (ADR 0329 §10). - -**A node with no profile yet.** The preparation needs the profile Betterbird makes at its first start: on a -node set up from nothing, the declared accounts are written at the second start from the launcher (or at -`betterbird_restart`). - -## Identities, calendars, address books, filters and tags (ADR 0329) - -Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on -that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as -`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve. -**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose -user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines. - -| setting | an entry | matched in the profile by | -|---|---|---| -| `identities` | by a name: `account` (its name in `accounts`), and for another identity than the account's default `address` and `name`; `reply-to`, `organization`, `signature` (lines), `signature-html`, `signature-above-quote`; `key` in a node's layer | the account's default (no `address`), or an identity of that account with the address | -| `calendars` | by a name: `name`, `url` (CalDAV), `color`, `read-only`, `reminders`, `user`, `invitations` (an identities entry); `key` in a node's layer | its address | -| `address-books` | by a name: `name`, `url` (CardDAV), `user`; `key` in a node's layer | its address | -| `tags` | by its key: `name`, `color`, `order` | its key | -| `filters` | by an account's name: the ordered list, each with `name`, `enabled`, `when` (new-mail, manual, after-junk, sending, archiving, periodic), `match` (all, any, every-message), `conditions` (`field`, `op`, `value`, in Betterbird's words) and `actions` (`action` in Betterbird's words; `folder` as a path within the account, or `value`) | owned whole per account | - -**Adopted as accounts are** (§7): an entry recorded on this node is kept by its key; one under the module's own -key (`ident_mesh_`, `mesh_` for a calendar or an address book) is its creation; a `key` in the -node's layer is adopted when the profile holds it and it matches; else one match is adopted, none is created -under the module's key, and several are refused with the remedy. The declared fields go into the user.js under -that key. An entry leaving the setting releases what was adopted (its lines go) and removes the preferences of -what was created, never a calendar's events, an address book's contacts or a cache. An entry no setting declares -stays the person's. Owl's accounts and calendars, an ICS calendar (its address is often the secret), local -calendars and address books are never declared (§13). - -**Filters are owned whole per declared account** (§8): the module writes that account's `msgFilterRules.dat` -from the list, each folder's path turned into its address on this node, so a filter added in Betterbird is gone -at the next apply (drift shows it first). Before its first write it keeps the replaced file in -`~/.config/betterbird/kept//`; when the account leaves `filters`, the module stops writing and the file -stays the person's; `betterbird_filters` says where the kept copy is. A filter a setting cannot hold as it is (a -folder of another account, an add-on's action, a news filter) is named and not derived. - -**Written only while Betterbird is closed** (§9): the lists in `prefs.js` and the filter files, because -Betterbird rewrites them as it runs and quits; the user.js is placed as before. `betterbird_check` counts per -kind the changes that wait. - -**Derived, then proposed one layer at a time** (§10): `betterbird_derive` asks every desktop node holding -Betterbird for what its profile holds. An entry on every such node goes into the mesh-wide layer with the value of -the node the derive was called on, and the answer lists each field another node holds differently (which then -shows as drift there); one on some nodes only goes into those nodes' own layers. An account whose filter file -holds no filter is not put in `filters`. With `propose`, the next layer (the mesh-wide one, then each node's) is -proposed with the layer as it stands beside the derived kinds; never while a proposal of the module's waits, and -never while a desktop node does not answer. `prefs` on the call carries values of the setting `prefs` into the -mesh-wide layer. A `key` is never proposed for the mesh-wide layer. - -## Tools - -They are served by the node's runtime as the operator account (ADR 0175). **They show the operator's own -configuration with its values, and never a credential** (ADR 0329 §1, §2): every answer passes the module's -credential check, which withholds a field whose name says password, token, secret or cookie and the password -part of any address. No answer carries mail, a calendar entry or a contact. - -| tool | does | -|---|---| -| `betterbird_status` (r) | whether it runs (pid, since, scope, children); what is installed and from where, its version beside the newest published build; the policy; Owl's version against the pin and whether it is active; where its output goes; the profile and how it was found; the user.js (declared, placed, in effect, with every declared entry as it resolved); the declared accounts by count; the add-ons; the preferences that decide how its windows and notifications look | -| `betterbird_prefs` (r) | named preferences or prefixes: value and source, and the default with the file that gives it. Withheld without its value: a name that can hold a credential, one that names an account, server, calendar or person, and every `extensions.*` but the theme's id. Values masked (the home as `~`, addresses and hosts) | -| `betterbird_config` (r; a with `keep`) | each managed preference: its lock, the declared value, Betterbird's default where the module keeps that, the profile's saved value, the value in effect, and drift; with `keep`, this node's layer of `prefs` proposed with the profile's values | -| `betterbird_config_apply` (r; a with `apply`) | a kept preference's drifting values listed; with `apply`, while Betterbird is quit, taken out of `prefs.js` | -| `betterbird_user_js` (r; a with `apply`) | the user.js composed from the settings against the profile's; `apply` places it, writes what Betterbird rewrites while it is closed, and counts what waits | -| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked | -| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js and writes what waited, starts it as `mesh-betterbird`. **Refused while a composer window is open**, unless `discard_unsent` is true, which only the operator's yes justifies | -| `betterbird_check` (r) | installed from the mesh's release; the policy and the module's defaults placed; no managed preference drifting; no ask to be the default mail client; Owl at its pin and active; one running; profile found; the user.js valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; per kind, the declared entries drifting or refused and the changes waiting for Betterbird to close; its output read | -| `betterbird_accounts` (r; a with `propose`) | the profile's accounts with their values and the declared account each is, Owl's by name and address and marked; with `propose`, `accounts` built from them and proposed in the mesh-wide layer as it stands | -| `betterbird_identities`, `betterbird_calendars`, `betterbird_address_books`, `betterbird_filters`, `betterbird_tags` (r) | the profile's entries with their keys and values beside the declared ones and how each resolved (adopted, created or refused with why); an ICS calendar by its name only, Owl's marked; `betterbird_filters` also where the kept filter files are | -| `betterbird_derive` (r; a with `propose`) | the five kinds derived from the desktop nodes' profiles, per layer, with the fields another node holds differently and what was left out; with `propose`, the next layer proposed; `here` answers this node's profile, as another node asks it | -| `betterbird_drift` (r) | per kind and declared entry: holds, differs (each field with both values), lacks, or refused; and the profile's entries nothing declares | -| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) | - -### A reminder on demand - -**It opens windows on the operator's screen**, and its description says so first, so a caller tells the -operator before calling it. On 2026-10-12 at 12:05 a test Betterbird with an empty profile carrying the -operator's theme looked like the operator's configuration lost; so the test is **visibly a test**: its -profile carries a `userChrome.css` that frames every window of the test Betterbird in red and runs a red -band reading MESH TEST across its top, and its event is called "MESH TEST reminder (a throw-away -Betterbird of the mesh, not your calendar)". The reminder window itself is left unstyled, because how -Betterbird draws it is what the test looks at; its title is the event's. A window title cannot be set by a -preference, so the marking is the band, the frame and the event's name. - -`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile -under `/run/user//mesh-betterbird-reminder-test/`, as the user unit -`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's -user.js (the setting `prefs`; never a declared account) and one calendar: a local `.ics` file with one -event, "MESH TEST reminder", whose reminder is -due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's -profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. The test's own -preferences come last in its user.js, so nothing declared or carried overrides them; one of them turns -Betterbird's update check off (`betterbird.update.checkIntervalDays` 0), because a fresh profile checks -at its start and, when a newer build is published, opens the About window on the operator's screen -(seen 2026-10-12). Then: - -- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`, - its depth (32: a visual with alpha, which the compositor blends), its map state; -- `xorg_screenshot` with that window shows it as the screen shows it. - -The unit removes its profile when it ends, however it ends (`ExecStopPost`): at its 10 minutes, on -`stop`, or when the test window is closed. The test Betterbird is never counted as the operator's: its -command line names its own profile, and `status`, `check`, `restart` and the user.js leave it out. - -The running Betterbird, its profile and its calendars are not touched: `--no-remote` keeps the two -apart. **`profiles.ini` and `installs.ini` are not rewritten either**, measured on the workstation -(2026-10-09): their content and modification time (June 2023) were the same before a `--profile` start, -while it ran, and after it ended. - -## The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx) - -| | | -|---|---| -| X class / instance | `eu.betterbird.Betterbird` / `Calendar` | -| role, type | `AlarmWindow`, `_NET_WM_WINDOW_TYPE_DIALOG` (i3 floats it) | -| depth | 32 (ARGB visual), like every Betterbird window there | -| size | 600×300, kept in the profile's `xulstore.json` | - -A window of depth 32 that its program has not drawn is transparent under a compositor, not black: -that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's -question. - -## What changes when it is pushed - -The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird -outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`): - -| | on a node with the community package (g14, shanks) | -|---|---| -| the package | `betterbird-bin` removed (quit Betterbird first) | -| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens | -| the policy, Owl | placed; Owl installed into the profile by Betterbird at its next start where it is missing | -| `/usr/local` | the start, the launcher entry, the tools' program | -| `~/.config/betterbird/` | `prefs.json`, `accounts.json`, `configuration.json`, `tags.json` (each empty until its setting is approved) | -| the profile | untouched until the next start from the launcher, `betterbird_user_js` with apply, or `betterbird_restart` | -| the configuration | the policy's locks at Betterbird's next start; the settings in the user.js from the next start. Pushed in the order above, the operator's 21 preferences are in `prefs` before the build arrives; pushed before that layer is approved, Betterbird runs with its own defaults for them until it is | - -## Relies on - -- A display server on the same machine (`x11-display`, ADR 0208 §3). -- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module - carries no window code of its own. -- `systemd-run` and `journalctl` of the account's user manager. -- The build seat's archive pinned by URL and sha256 (mesh-controller, ADR 0316 §2): a controller that does - not know it refuses this manifest. -- The controller's `settings` verb, for every proposal and to read a layer as it stands; its `nodes` and `node` - verbs, for which machines hold the module; and the module's own `betterbird_derive` on those machines - (`invokes`). - -## Files it does not know - -Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). - -- `~/.config/betterbird`: holds only what this module declares and its own tools' records: `accounts-record.json` (`cmd/betterbird-tools/adoption.go:36`), `configuration-record.json` (`owned.go:33`) and the `kept/` folder of replaced filter files (`filters.go:261`); anything else is moved out by the node-engine to `~/.local/state/mesh-moved/betterbird/` with a list, never deleted (ADR 0422 §4). -- `/opt/betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `/opt/betterbird/owl`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `/opt/betterbird/distribution`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `/opt/betterbird/defaults/pref`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `/usr/local/lib/mesh-betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). diff --git a/modules/betterbird/cmd/betterbird-tools/accounts.go b/modules/betterbird/cmd/betterbird-tools/accounts.go deleted file mode 100644 index 227af178..00000000 --- a/modules/betterbird/cmd/betterbird-tools/accounts.go +++ /dev/null @@ -1,842 +0,0 @@ -package main - -// The operator's mail accounts, declared without credentials (novox/hq ADR 0316 §10–§14, to-be 56 §4). -// -// The setting `accounts` reaches the machine as ~/.config/betterbird/accounts.json, a file the node-engine -// writes from the module's settings layers. Per account it names the address, the incoming server and the -// outgoing server; never a password or a token, and the whole file is refused when any key in it is shaped -// like one. Betterbird's accounts are preferences, so a declared account becomes: -// -// - its declared fields as preferences in the user.js the module places, where they win at every start -// (§11): under the keys of the account the profile already holds at that address, which the module adopts -// (novox/hq ADR 0317), or, where the profile holds none, under keys of the module's own that end in -// mesh_ (account_mesh_, id_mesh_, server_mesh_, smtp_mesh_); -// - for an account the module created, its key in the account list (mail.accountmanager.accounts) and in the -// list of outgoing servers (mail.smtpservers) of the profile's prefs.js: written into, never owned, and -// only while Betterbird is not running, because it rewrites prefs.js when it quits (§12). Every other key -// keeps its place, and an adopted account's keys are in the lists already. -// -// Which account is adopted, created or refused is adoption.go's. -// -// Nobody's password passes through here: an account signs in at its first use, in Betterbird's own window, -// and Betterbird keeps the result in its login store (§14). Of that store the module reads only the server -// names each entry is kept for, which Betterbird writes in the clear, to count the declared accounts that -// have not signed in yet (§16); never a user name or a password, which are encrypted there anyway. - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// accountsFile is the setting accounts on the machine, written by the node-engine. -const accountsFile = ".config/betterbird/accounts.json" - -// Account is one declared mail account, in the shape of the setting accounts: keyed by its name. -type Account struct { - Address string `json:"address"` - // FromName is the name the account's mail is sent under. Absent: Betterbird's, none. - FromName string `json:"from-name,omitempty"` - // Label is what Betterbird shows the account as. Absent: its address. - Label string `json:"label,omitempty"` - Incoming Server `json:"incoming"` - Outgoing Server `json:"outgoing"` - // Key is the profile's account key to adopt, given only in a node's own layer to settle an address the - // profile holds more than once (novox/hq ADR 0317 §8). - Key string `json:"key,omitempty"` -} - -// Server is an account's incoming or outgoing server: never a password or a token. -type Server struct { - // Kind is imap or pop3, for the incoming server; the outgoing one is smtp and says nothing. - Kind string `json:"kind,omitempty"` - Host string `json:"host"` - Port int `json:"port,omitempty"` - Security string `json:"security"` - SignIn string `json:"sign-in"` - User string `json:"user,omitempty"` -} - -// The words a server's connection security and sign-in method are said in, and Betterbird's numbers for -// them (socketType / try_ssl, authMethod). -var ( - securities = map[string]int{"none": 0, "starttls": 2, "tls": 3} - signIns = map[string]int{"none": 1, "password": 3, "encrypted-password": 4, "kerberos": 5, "ntlm": 6, - "tls-certificate": 7, "oauth": 10} -) - -func wordFor(words map[string]int, n int) string { - for w, v := range words { - if v == n { - return w - } - } - return "" -} - -var ( - accountName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`) - addressShape = regexp.MustCompile(`^[^\s@"\\]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$`) - hostShape = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?$`) - profileKey = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`) - // plainText is a value written into a preference: no quote, backslash or line break. - plainText = regexp.MustCompile(`^[^"\\\n\r]*$`) - // secretKey is a key the accounts file may never carry, at any depth: the setting holds no credential. - secretKey = regexp.MustCompile(`(?i)(pass|secret|token|oauth.?refresh|refresh|credential|api.?key|private.?key|cookie|bearer)`) -) - -// keysOf are the module's own keys for a declared account: each ends in mesh_. -func keysOf(name string) (account, identity, server, smtp string) { - return "account_mesh_" + name, "id_mesh_" + name, "server_mesh_" + name, "smtp_mesh_" + name -} - -// ownKey is a key of an account list the module wrote, and the name it was declared under. -var ownKey = regexp.MustCompile(`^(?:account|smtp)_mesh_([a-z][a-z0-9]{0,30})$`) - -// readAccounts reads the declared accounts. A missing file is no account (the module is older here); a file -// that carries a key shaped like a credential, an unknown field or a value that cannot be written is refused -// whole, naming the key and never a value. -func (m *Machine) readAccounts() (map[string]Account, bool, error) { - raw, err := readBounded(m.home(accountsFile)) - if errors.Is(err, os.ErrNotExist) { - return map[string]Account{}, false, nil - } - if err != nil { - return nil, false, err - } - accounts, err := parseAccounts(raw) - return accounts, true, err -} - -func parseAccounts(raw []byte) (map[string]Account, error) { - var tree any - if err := json.Unmarshal(raw, &tree); err != nil { - return nil, fmt.Errorf("~/%s is not JSON: %v", accountsFile, err) - } - if where := secretKeyIn(tree, ""); where != "" { - // An account's name is said by nothing: its segment of the path is withheld. - if parts := strings.SplitN(where, ".", 3); len(parts) == 3 && parts[0] == "accounts" { - where = "accounts.." + parts[2] - } - return nil, fmt.Errorf("the setting accounts carries %s, a key shaped like a credential: an account is declared "+ - "without its password or token, which it signs in with at its first use (novox/hq ADR 0316 §10, §14). "+ - "Take it out at the controller's terminal", where) - } - var top map[string]json.RawMessage - if err := json.Unmarshal(raw, &top); err != nil { - return nil, fmt.Errorf("~/%s is not an object: %v", accountsFile, err) - } - accounts := map[string]Account{} - if body, ok := top["accounts"]; ok && string(body) != "null" { - dec := json.NewDecoder(bytes.NewReader(body)) - dec.DisallowUnknownFields() - if err := dec.Decode(&accounts); err != nil { - return nil, fmt.Errorf("the setting accounts is not a map of accounts by name: %v", err) - } - } - var problems []string - names := sortedNames(accounts) - for i, name := range names { - problems = append(problems, accountProblems(fmt.Sprintf("declared account %d of %d", i+1, len(names)), - name, accounts[name])...) - } - if len(problems) > 0 { - return nil, fmt.Errorf("the setting accounts is refused: %s", strings.Join(problems, "; ")) - } - return accounts, nil -} - -// secretKeyIn is the path of the first key shaped like a credential in a JSON tree, or "". -func secretKeyIn(node any, at string) string { - switch v := node.(type) { - case map[string]any: - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - here := k - if at != "" { - here = at + "." + k - } - if secretKey.MatchString(k) { - return here - } - if w := secretKeyIn(v[k], here); w != "" { - return w - } - } - case []any: - for i, x := range v { - if w := secretKeyIn(x, fmt.Sprintf("%s[%d]", at, i)); w != "" { - return w - } - } - } - return "" -} - -func sortedNames(accounts map[string]Account) []string { - names := make([]string, 0, len(accounts)) - for n := range accounts { - names = append(names, n) - } - sort.Strings(names) - return names -} - -// accountProblems is what is wrong with one declared account, said of it by its place (label) and never by a -// value of it, its name included (novox/hq ADR 0316 §16). -func accountProblems(label, name string, a Account) []string { - var out []string - say := func(format string, args ...any) { out = append(out, label+": "+fmt.Sprintf(format, args...)) } - if !accountName.MatchString(name) { - say("an account's name is lower-case letters and digits, starting with a letter (it names the module's keys, mesh_)") - } - if !addressShape.MatchString(a.Address) { - say("the address is not a mail address") - } - if a.Key != "" && !profileKey.MatchString(a.Key) { - say("key is an account key of the profile, such as account2") - } - for field, v := range map[string]string{"from-name": a.FromName, "label": a.Label} { - if !plainText.MatchString(v) { - say("%s holds a quote, a backslash or a line break", field) - } - } - for side, s := range map[string]Server{"incoming": a.Incoming, "outgoing": a.Outgoing} { - switch { - case side == "incoming" && s.Kind != "imap" && s.Kind != "pop3": - say("the incoming server's kind is imap or pop3") - case side == "outgoing" && s.Kind != "" && s.Kind != "smtp": - say("the outgoing server is smtp") - } - if !hostShape.MatchString(s.Host) { - say("the %s server's host is not a host name", side) - } - if s.Port < 0 || s.Port > 65535 { - say("the %s server's port is not a port", side) - } - if _, ok := securities[s.Security]; !ok { - say("the %s server's security is tls, starttls or none", side) - } - if _, ok := signIns[s.SignIn]; !ok || (side == "incoming" && s.SignIn == "none") { - say("the %s server's sign-in is password, encrypted-password, oauth, kerberos, ntlm or tls-certificate%s", - side, map[bool]string{true: ", or none", false: ""}[side == "outgoing"]) - } - if !plainText.MatchString(s.User) { - say("the %s server's user holds a quote, a backslash or a line break", side) - } - } - return out -} - -// quoted is a preference's string value as user.js writes it. -func quoted(s string) string { return strconv.Quote(s) } - -// declaredPrefs are a declared account's fields as preferences under the keys it resolved to, name to value -// as user.js writes it, in a fixed order. Only the fields the setting names (novox/hq ADR 0317 §5): never -// the account's directory, folders, filters or caches. An adopted account keeps its kind, the outgoing server -// its identity names, and its own name and sender's name where the declaration says none; a created one -// gets every field. -func declaredPrefs(k keySet, a Account, adopted bool) [][2]string { - label := a.Label - if label == "" && !adopted { - label = a.Address - } - inPort := a.Incoming.Port - if inPort == 0 { - inPort = defaultPort(a.Incoming.Kind, a.Incoming.Security) - } - outPort := a.Outgoing.Port - if outPort == 0 { - outPort = defaultPort("smtp", a.Outgoing.Security) - } - var out [][2]string - add := func(key, value string) { out = append(out, [2]string{key, value}) } - if !adopted { - add("mail.account."+k.Account+".identities", quoted(k.Identity)) - add("mail.account."+k.Account+".server", quoted(k.Server)) - } - if a.FromName != "" || !adopted { - add("mail.identity."+k.Identity+".fullName", quoted(a.FromName)) - } - add("mail.identity."+k.Identity+".useremail", quoted(a.Address)) - if !adopted { - add("mail.identity."+k.Identity+".smtpServer", quoted(k.SMTP)) - add("mail.identity."+k.Identity+".valid", "true") - add("mail.server."+k.Server+".type", quoted(a.Incoming.Kind)) - } - add("mail.server."+k.Server+".hostname", quoted(a.Incoming.Host)) - add("mail.server."+k.Server+".port", strconv.Itoa(inPort)) - add("mail.server."+k.Server+".socketType", strconv.Itoa(securities[a.Incoming.Security])) - add("mail.server."+k.Server+".authMethod", strconv.Itoa(signIns[a.Incoming.SignIn])) - add("mail.server."+k.Server+".userName", quoted(a.Incoming.User)) - if label != "" { - add("mail.server."+k.Server+".name", quoted(label)) - } - add("mail.smtpserver."+k.SMTP+".hostname", quoted(a.Outgoing.Host)) - add("mail.smtpserver."+k.SMTP+".port", strconv.Itoa(outPort)) - add("mail.smtpserver."+k.SMTP+".try_ssl", strconv.Itoa(securities[a.Outgoing.Security])) - add("mail.smtpserver."+k.SMTP+".authMethod", strconv.Itoa(signIns[a.Outgoing.SignIn])) - add("mail.smtpserver."+k.SMTP+".username", quoted(a.Outgoing.User)) - if label != "" { - add("mail.smtpserver."+k.SMTP+".description", quoted(label)) - } - return out -} - -// defaultPort is the port a server listens on when the declaration says none. -func defaultPort(kind, security string) int { - switch { - case kind == "imap" && security == "tls": - return 993 - case kind == "imap": - return 143 - case kind == "pop3" && security == "tls": - return 995 - case kind == "pop3": - return 110 - case security == "tls": - return 465 - case security == "starttls": - return 587 - } - return 25 -} - -// composeUserJS is the user.js the module places in the profile: the declared file (the setting prefs), then -// each adopted or created account's declared fields under its keys. A refused account has none. -func composeUserJS(declaredText string, accounts map[string]Account, resolved []resolvedAccount) string { - var lines []string - for _, r := range resolved { - if r.How == refused { - continue - } - lines = append(lines, fmt.Sprintf("// declared account %d: %s under %s", r.Place, r.How, r.Keys.Account)) - for _, p := range declaredPrefs(r.Keys, accounts[r.name], r.How == adopted) { - lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(p[0]), p[1])) - } - } - if len(lines) == 0 { - return declaredText - } - return strings.TrimRight(declaredText, "\n") + "\n\n" + - "// The accounts the setting accounts declares (novox/hq ADR 0316 §11, ADR 0317): the fields it names, under\n" + - "// the keys of the account adopted or created. No password or token: an account signs in at its first use.\n" + - strings.Join(lines, "\n") + "\n" -} - -// profileAccount is one account the profile holds, as its preferences say it. -type profileAccount struct { - Key, Server, Kind string - // Identities are its identities, the default first, and Addresses their addresses in that order. - Identities []string - Addresses []string - Owl, Ours bool -} - -// profileAccounts reads the accounts the profile's list names, from prefs.js and user.js (user.js wins). -func profileAccounts(prefs map[string]string) []profileAccount { - var out []profileAccount - for _, key := range listOf(prefs["mail.accountmanager.accounts"]) { - a := profileAccount{Key: key, Server: unquote(prefs["mail.account."+key+".server"])} - a.Kind = unquote(prefs["mail.server."+a.Server+".type"]) - a.Owl = strings.HasPrefix(a.Kind, "owl") - a.Ours = ownKey.MatchString(key) - for _, id := range listOf(prefs["mail.account."+key+".identities"]) { - a.Identities = append(a.Identities, id) - a.Addresses = append(a.Addresses, unquote(prefs["mail.identity."+id+".useremail"])) - } - out = append(out, a) - } - return out -} - -// mergedPrefs are a profile's preferences as Betterbird starts with them: prefs.js, then user.js over it. -func mergedPrefs(profile string) map[string]string { - prefsJS, _ := readBounded(filepath.Join(profile, "prefs.js")) - userJS, _ := readBounded(filepath.Join(profile, "user.js")) - out := readPrefs(prefsJS) - for k, v := range readPrefs(userJS) { - out[k] = v - } - return out -} - -// listOf reads a comma-separated list preference's value. -func listOf(v string) []string { - var out []string - for _, k := range strings.Split(unquote(v), ",") { - if k = strings.TrimSpace(k); k != "" { - out = append(out, k) - } - } - return out -} - -// unquote is a preference's string value without its quotes; any other value as written. -func unquote(v string) string { - if s, err := strconv.Unquote(v); err == nil { - return s - } - return v -} - -// writeAccountLists writes the module's keys into the profile's account list and list of outgoing servers -// (§12): the declared accounts' keys added at the end, the module's keys of accounts no longer declared taken -// out, every other key and line kept as it was. Only while Betterbird is not running. Says what changed. -func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount) (string, error) { - path := filepath.Join(profile, "prefs.js") - raw, err := readBounded(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", err - } - var wantAccounts, wantSMTP []string - for _, r := range resolved { - if r.How == created { - wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP) - } - } - out, changed := withListed(string(raw), map[string][]string{ - "mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}) - if !changed { - return "unchanged", nil - } - // Only while Betterbird is closed: no main process and no lock on the profile (novox/hq ADR 0316 §12, ADR 0329 §9). - if why := m.closedFor(profile); why != "" { - return "", errors.New(why + ": the account list is written at its next start from the launcher, or by betterbird_restart") - } - mode := os.FileMode(0o600) - if st, err := os.Stat(path); err == nil { - mode = st.Mode().Perm() - } - if err := writeAtomic(path, []byte(out), mode); err != nil { - return "", err - } - return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil -} - -// withListed is prefs.js with each list preference holding the module's wanted keys: its own keys that are -// not wanted taken out, the wanted ones not there added at its end, everything else as it was. A list the -// file does not hold yet is added as a line, when anything is wanted in it. -func withListed(text string, want map[string][]string) (string, bool) { - changed := false - done := map[string]bool{} - var lines []string - s := bufio.NewScanner(strings.NewReader(text)) - s.Buffer(make([]byte, 64<<10), 4<<20) - for s.Scan() { - line := s.Text() - if mm := prefLine.FindStringSubmatch(line); mm != nil { - if keys, ok := want[mm[1]]; ok && !done[mm[1]] { - done[mm[1]] = true - next := listedWith(listOf(mm[2]), keys) - if next != strings.Join(listOf(mm[2]), ",") { - line = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), quoted(next)) - changed = true - } - } - } - lines = append(lines, line) - } - names := make([]string, 0, len(want)) - for n := range want { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - if !done[n] && len(want[n]) > 0 { - lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(n), quoted(strings.Join(want[n], ",")))) - changed = true - } - } - out := strings.Join(lines, "\n") - if text == "" || strings.HasSuffix(text, "\n") { - out += "\n" - } - return out, changed -} - -// listedWith is a list with the module's own keys made the wanted ones, every other key in its place. -func listedWith(have, wanted []string) string { - want := map[string]bool{} - for _, k := range wanted { - want[k] = true - } - var out []string - there := map[string]bool{} - for _, k := range have { - if ownKey.MatchString(k) && !want[k] { - continue - } - out = append(out, k) - there[k] = true - } - for _, k := range wanted { - if !there[k] { - out = append(out, k) - } - } - return strings.Join(out, ",") -} - -// AccountCount is what status and check say of the accounts: numbers, places and keys, never an address, a -// host or a name (novox/hq ADR 0316 §16, ADR 0317 §3). -type AccountCount struct { - Declared int `json:"declared"` - // InList are the declared accounts the profile's account list names, adopted or created. - InList int `json:"declared_in_the_list"` - // Others are the accounts the profile holds that no declaration names (the work account in Owl among them). - Others int `json:"other_accounts"` - // WithoutSignIn are the declared accounts with no sign-in kept in Betterbird's login store yet. - WithoutSignIn int `json:"declared_without_a_sign_in,omitempty"` - // Each is every declared account by its place in the setting: adopted, created or refused, and its keys. - Each []resolvedAccount `json:"each,omitempty"` - Problem string `json:"problem,omitempty"` -} - -// countAccounts counts the declared accounts against the profile's. -func (m *Machine) countAccounts(profile, profileDir string) AccountCount { - var c AccountCount - accounts, _, err := m.readAccounts() - if err != nil { - c.Problem = err.Error() - return c - } - c.Declared = len(accounts) - prefs := mergedPrefs(profile) - have := profileAccounts(prefs) - c.Each = resolveAccounts(accounts, prefs, m.readRecord(profileDir)) - mine := map[string]bool{} - refusedN := 0 - for _, r := range c.Each { - if r.How == refused { - refusedN++ - continue - } - mine[r.Keys.Account] = true - } - for _, a := range have { - switch { - case mine[a.Key]: - c.InList++ - case a.Kind != "none": - c.Others++ - } - } - if refusedN > 0 { - c.Problem = fmt.Sprintf("%d declared account(s) are refused: each says why and what to do", refusedN) - } - c.WithoutSignIn = len(withoutSignIn(accounts, keptSignIns(profile))) - return c -} - -// keptSignIns are the server names Betterbird's login store keeps a sign-in for: the `hostname` of each -// entry of logins.json, which Betterbird writes in the clear. Nothing else of the file is read. -func keptSignIns(profile string) map[string]bool { - raw, err := readBounded(filepath.Join(profile, "logins.json")) - out := map[string]bool{} - if err != nil { - return out - } - var store struct { - Logins []struct { - Hostname string `json:"hostname"` - } `json:"logins"` - } - if json.Unmarshal(raw, &store) != nil { - return out - } - for _, l := range store.Logins { - out[strings.ToLower(l.Hostname)] = true - } - return out -} - -// oauthIssuers are the sign-in services of the providers whose servers sign in through OAuth, by a suffix of -// the server's host: their kept sign-in is under the service's name, not the server's. -var oauthIssuers = map[string]string{"gmail.com": "oauth://accounts.google.com", - "googlemail.com": "oauth://accounts.google.com", "office365.com": "oauth://login.microsoftonline.com", - "outlook.com": "oauth://login.microsoftonline.com"} - -// withoutSignIn are the declared accounts whose incoming server has no sign-in kept yet. A server signed in -// to without a password (none, kerberos, ntlm, a certificate) keeps nothing and is never counted. -func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string { - var out []string - for _, name := range sortedNames(accounts) { - in := accounts[name].Incoming - switch in.SignIn { - case "password", "encrypted-password": - if !kept[strings.ToLower(in.Kind+"://"+in.Host)] { - out = append(out, name) - } - case "oauth": - issuer := "" - for suffix, i := range oauthIssuers { - if strings.HasSuffix(strings.ToLower(in.Host), suffix) { - issuer = i - } - } - found := false - for k := range kept { - if (issuer != "" && k == issuer) || (issuer == "" && strings.HasPrefix(k, "oauth://")) { - found = true - } - } - if !found { - out = append(out, name) - } - } - } - return out -} - -// AccountsAnswer is what betterbird_accounts answers its caller: the profile's accounts with the operator's own -// values (novox/hq ADR 0329 §1, §12), Owl's marked and by name and address only (§13), and counts and kinds. -// Never a password, a token or anything of the login store (§2). -type AccountsAnswer struct { - Profile string `json:"profile"` - // Accounts are the profile's accounts, in its list's order. - Accounts []listedAccount `json:"accounts"` - // Derived is the value of the setting accounts the propose built, as it was proposed (ADR 0329 §10). - Derived map[string]Account `json:"derived,omitempty"` - // Declarable are the profile's mail accounts a declaration can hold (IMAP and POP3), by kind. - Declarable map[string]int `json:"declarable"` - // KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13). - KeptByOwl int `json:"kept_by_owl"` - // NotDeclarable are accounts of another kind (local folders, feeds, news, chat), by kind. - NotDeclarable map[string]int `json:"not_declarable,omitempty"` - // AlreadyDeclared are the declarable accounts under the module's own keys. - AlreadyDeclared int `json:"under_the_modules_keys"` - // Proposed is the controller's answer to the proposal, with every address and host masked. - Proposed string `json:"proposed,omitempty"` - Note string `json:"note"` -} - -// listedAccount is one account of the profile as betterbird_accounts lists it. -type listedAccount struct { - Key string `json:"key"` - Kind string `json:"kind"` - // DeclaredAs is the name the setting accounts declares it under, or empty: the person's own. - DeclaredAs string `json:"declared_as,omitempty"` - Owl bool `json:"owl,omitempty"` - // Name is what Betterbird shows it as, and Address its default identity's. - Name string `json:"name,omitempty"` - Address string `json:"address,omitempty"` - Value *Account `json:"value,omitempty"` - Note string `json:"note,omitempty"` -} - -// profileDeclarable reads the profile's accounts: the value a declaration of them would be, by name, and -// what is answered of them. -func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) { - p, err := m.profile() - if err != nil { - return nil, AccountsAnswer{}, err - } - prefs := mergedPrefs(p.path) - a := AccountsAnswer{Profile: p.Dir, Accounts: []listedAccount{}, Declarable: map[string]int{}, NotDeclarable: map[string]int{}} - value := map[string]Account{} - used := map[string]bool{} - declaredAs := map[string]string{} - if accounts, _, err := m.readAccounts(); err == nil { - for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) { - if r.How != refused { - declaredAs[r.Keys.Account] = r.name - } - } - } - for _, pa := range profileAccounts(prefs) { - listed := listedAccount{Key: pa.Key, Kind: pa.Kind, DeclaredAs: declaredAs[pa.Key], Owl: pa.Owl, - Name: unquote(prefs["mail.server."+pa.Server+".name"])} - if len(pa.Addresses) > 0 { - listed.Address = pa.Addresses[0] - } - switch { - case pa.Owl: - a.KeptByOwl++ - listed.Note = "Owl's: never declared (novox/hq ADR 0316 §13)" - a.Accounts = append(a.Accounts, listed) - continue - case pa.Kind != "imap" && pa.Kind != "pop3": - kind := pa.Kind - if kind == "none" { - kind = "local folders" - } - a.NotDeclarable[kind]++ - listed.Note = "not an account a declaration holds" - a.Accounts = append(a.Accounts, listed) - continue - } - a.Declarable[pa.Kind]++ - if pa.Ours { - a.AlreadyDeclared++ - } - address := "" - if len(pa.Addresses) > 0 { - address = pa.Addresses[0] - } - name := nameFor(pa, address, used) - if declaredAs[pa.Key] != "" { - name = declaredAs[pa.Key] - } - used[name] = true - acc := readAccount(prefs, pa) - value[name] = acc - listed.Value = &acc - a.Accounts = append(a.Accounts, listed) - } - if len(a.NotDeclarable) == 0 { - a.NotDeclarable = nil - } - return value, a, nil -} - -// asker calls a verb of the mesh as this module, through the runtime: a variable so a test answers. -var asker = stdio.Ask - -// ProfileAccounts says, by count and kind, which accounts the profile in use holds; with propose it builds -// the setting accounts from them (every account but Owl's, without any credential) and proposes it through -// the controller's settings verb for the operator's Approve (novox/hq ADR 0317 §9, ADR 0277). The value -// never comes back to the caller: the operator reads it whole at their desk (`settings proposals `) and -// checks the fingerprint the ask shows. -func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) { - value, a, err := m.profileDeclarable() - if err != nil { - return a, err - } - a.Note = "the profile's accounts with their values, never a password or token (novox/hq ADR 0329 §1, §2). With " + - "propose, the setting accounts is built from them (never Owl's) and proposed to the operator in the mesh-wide " + - "layer as it stands, who approves it on a channel that proves who answers (novox/hq ADR 0317 §9)" - if !propose { - return a, nil - } - if len(value) == 0 { - return a, errors.New("the profile holds no IMAP or POP3 account to declare: nothing was proposed") - } - var problems []string - for i, name := range sortedNames(value) { - if p := accountProblems("", name, value[name]); len(p) > 0 { - problems = append(problems, fmt.Sprintf("account %d of %d: %d field(s) cannot be declared as the profile "+ - "holds them", i+1, len(value), len(p))) - } - } - if len(problems) > 0 { - return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name", - strings.Join(problems, "; ")) - } - // A layer is replaced whole: the proposal carries the mesh-wide layer as it stands, with this value of accounts. - layer, err := readLayer("") - if err != nil { - return a, fmt.Errorf("nothing was proposed: %v", err) - } - layer["accounts"] = value - a.Derived = value - said, err := proposeLayer("", layer, false) - a.Proposed = m.maskValue(said) - return a, err -} - -// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output}, -// or the protocol's content list. -func verbSaid(raw json.RawMessage) (string, bool) { - var s string - if json.Unmarshal(raw, &s) == nil { - return verbSaid(json.RawMessage(s)) - } - var m map[string]json.RawMessage - if json.Unmarshal(raw, &m) != nil { - return string(raw), true - } - if content, has := m["content"]; has { - var items []struct { - Text string `json:"text"` - } - var e struct { - IsError bool `json:"isError"` - } - _ = json.Unmarshal(raw, &e) - if json.Unmarshal(content, &items) == nil && len(items) > 0 { - out, ok := verbSaid(json.RawMessage(items[0].Text)) - return out, ok && !e.IsError - } - } - if okRaw, has := m["ok"]; has { - var ok bool - _ = json.Unmarshal(okRaw, &ok) - var output string - _ = json.Unmarshal(m["output"], &output) - return output, ok - } - return string(raw), true -} - -// readAccount reads one profile account's preferences into the shape of the setting. -func readAccount(prefs map[string]string, pa profileAccount) Account { - num := func(key string, def int) int { - if n, err := strconv.Atoi(prefs[key]); err == nil { - return n - } - return def - } - text := func(key string) string { return unquote(prefs[key]) } - srv := "mail.server." + pa.Server + "." - acc := Account{Incoming: Server{Kind: pa.Kind, Host: text(srv + "hostname"), User: text(srv + "userName"), - Security: wordFor(securities, num(srv+"socketType", 0)), SignIn: wordFor(signIns, num(srv+"authMethod", 3))}} - acc.Incoming.Port = num(srv+"port", defaultPort(pa.Kind, acc.Incoming.Security)) - ids := listOf(prefs["mail.account."+pa.Key+".identities"]) - if len(ids) > 0 { - id := "mail.identity." + ids[0] + "." - acc.Address, acc.FromName = text(id+"useremail"), text(id+"fullName") - smtp := text(id + "smtpServer") - if smtp == "" { - smtp = text("mail.smtp.defaultserver") - } - if smtp != "" { - s := "mail.smtpserver." + smtp + "." - acc.Outgoing = Server{Host: text(s + "hostname"), User: text(s + "username"), - Security: wordFor(securities, num(s+"try_ssl", 0)), SignIn: wordFor(signIns, num(s+"authMethod", 3))} - acc.Outgoing.Port = num(s+"port", defaultPort("smtp", acc.Outgoing.Security)) - } - } - if label := text(srv + "name"); label != "" && label != acc.Address { - acc.Label = label - } - return acc -} - -// nameFor is the name a profile account is answered under: its own name when the module declared it, else -// the first label of its address's domain, made unique. -func nameFor(pa profileAccount, address string, used map[string]bool) string { - if mm := ownKey.FindStringSubmatch(pa.Key); mm != nil { - return mm[1] - } - base := "account" - if _, domain, ok := strings.Cut(address, "@"); ok { - label, _, _ := strings.Cut(strings.ToLower(domain), ".") - label = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(label, "") - if label != "" && label[0] >= 'a' && label[0] <= 'z' { - base = label - } - } - if len(base) > 28 { - base = base[:28] - } - name := base - for i := 2; used[name]; i++ { - name = base + strconv.Itoa(i) - } - return name -} diff --git a/modules/betterbird/cmd/betterbird-tools/accounts_test.go b/modules/betterbird/cmd/betterbird-tools/accounts_test.go deleted file mode 100644 index 0e96ab44..00000000 --- a/modules/betterbird/cmd/betterbird-tools/accounts_test.go +++ /dev/null @@ -1,567 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// The setting accounts (novox/hq ADR 0316 §10–§16, ADR 0317 §9 for betterbird_accounts): declared without -// credentials, written under the module's own keys, the account list written into and never owned, refused -// rather than duplicated, and said by count. - -const personal = `{"accounts": { - "personal": {"address": "me@mail.example.org", "from-name": "Me", - "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password", "user": "me"}, - "outgoing": {"host": "smtp.example.org", "port": 587, "security": "starttls", "sign-in": "password", "user": "me"}} -}}` - -// profileWithAccounts is a profile holding an account of the person's at another address, Owl's work account, -// local folders, and an account list in that order. -func (f *fake) profileWithAccounts() { - f.betterbird() - f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ - `user_pref("mail.account.account1.server", "server1");`+"\n"+ - `user_pref("mail.account.account1.identities", "id1");`+"\n"+ - `user_pref("mail.account.account2.server", "server2");`+"\n"+ - `user_pref("mail.account.account3.server", "server3");`+"\n"+ - `user_pref("mail.account.account3.identities", "id3");`+"\n"+ - `user_pref("mail.accountmanager.accounts", "account1,account3,account2");`+"\n"+ - `user_pref("mail.identity.id1.useremail", "other@mail.example.org");`+"\n"+ - `user_pref("mail.identity.id1.fullName", "Other");`+"\n"+ - `user_pref("mail.identity.id1.smtpServer", "smtp1");`+"\n"+ - `user_pref("mail.identity.id3.useremail", "work@corp.example.com");`+"\n"+ - `user_pref("mail.server.server1.type", "imap");`+"\n"+ - `user_pref("mail.server.server1.hostname", "imap.example.org");`+"\n"+ - `user_pref("mail.server.server1.socketType", 3);`+"\n"+ - `user_pref("mail.server.server1.authMethod", 10);`+"\n"+ - `user_pref("mail.server.server1.userName", "other");`+"\n"+ - `user_pref("mail.server.server1.directory", "/home/operator/.thunderbird/x/ImapMail/imap.example.org");`+"\n"+ - `user_pref("mail.server.server2.type", "none");`+"\n"+ - `user_pref("mail.server.server3.type", "owl-ews");`+"\n"+ - `user_pref("mail.smtpserver.smtp1.hostname", "smtp.example.org");`+"\n"+ - `user_pref("mail.smtpserver.smtp1.try_ssl", 3);`+"\n"+ - `user_pref("mail.smtpserver.smtp1.authMethod", 10);`+"\n"+ - `user_pref("mail.smtpserver.smtp1.username", "other");`+"\n"+ - `user_pref("mail.smtpservers", "smtp1");`+"\n"+ - `user_pref("mailnews.tags.version", 2);`+"\n") -} - -func (f *fake) declareAccounts(body string) { - f.write(testHome+"/"+accountsFile, body) -} - -func (f *fake) read(path string) string { - f.t.Helper() - raw, err := os.ReadFile(filepath.Join(f.Root, path)) - if err != nil { - f.t.Fatal(err) - } - return string(raw) -} - -func TestAnAccountsValueCarryingAPasswordLikeFieldIsRefusedWithoutItsValue(t *testing.T) { - for _, body := range []string{ - `{"accounts": {"personal": {"address": "me@mail.example.org", "password": "hunter2"}}}`, - `{"accounts": {"personal": {"incoming": {"oauth_refresh": "1//hunter2"}}}}`, - `{"token": "hunter2", "accounts": {}}`, - } { - _, err := parseAccounts([]byte(body)) - if err == nil || !strings.Contains(err.Error(), "shaped like a credential") || strings.Contains(err.Error(), "hunter2") { - t.Errorf("%s: %v", body, err) - } - } - // An unknown field is refused too, and a value that cannot be declared, said by the account's place. - if _, err := parseAccounts([]byte(`{"accounts": {"personal": {"address": "me@mail.example.org", "keychain": 1}}}`)); err == nil { - t.Error("an unknown field was taken") - } - _, err := parseAccounts([]byte(strings.Replace(personal, `"security": "tls"`, `"security": "maybe"`, 1))) - if err == nil || !strings.Contains(err.Error(), "declared account 1 of 1") || strings.Contains(err.Error(), "personal") || - strings.Contains(err.Error(), "example.org") { - t.Errorf("a bad value is said by the account's place, never its name or a value: %v", err) - } -} - -func TestTheSettingPrefsRefusesAnAccountsPreferences(t *testing.T) { - for _, name := range []string{"mail.account.account1.server", "mail.identity.id1.useremail", "mail.server.server1.hostname", - "mail.smtpserver.smtp1.hostname", "mail.accountmanager.accounts", "mail.smtpservers"} { - err := validUserJS(mark + ")\nuser_pref(\"" + name + "\", \"x\");\n") - if err == nil || !strings.Contains(err.Error(), "setting accounts") { - t.Errorf("%s was taken in prefs: %v", name, err) - } - } - if err := validUserJS(mark + ")\nuser_pref(\"calendar.alarms.show\", true);\n"); err != nil { - t.Fatal(err) - } -} - -// noPackage answers pacman as a machine without the community package. -func (f *fake) noPackage() { - f.answer = func(name string, args []string) Output { - if name == "pacman" { - return Output{Code: 1} - } - return Output{} - } -} - -// userPrefLines are the user_pref lines of a user.js, by preference name. -func userPrefLines(t *testing.T, text string) map[string]string { - t.Helper() - out := map[string]string{} - for _, line := range strings.Split(text, "\n") { - if !strings.HasPrefix(line, "user_pref(") { - continue - } - if !statement.MatchString(line) { - t.Errorf("not a statement Betterbird reads as one: %s", line) - } - out[line[len(`user_pref("`):strings.Index(line, `",`)]] = line - } - return out -} - -func TestADeclaredAccountTheProfileDoesNotHoldIsCreatedUnderTheModulesKeysAndListed(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(personal) - // Betterbird is running: the user.js is placed, the list waits for its next start. - u, err := f.userJS(true) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "running") { - t.Fatalf("the list is never written while Betterbird runs: %+v", u) - } - if len(u.Each) != 1 || u.Each[0].How != created || u.Each[0].Keys != (keySet{"account_mesh_personal", "id_mesh_personal", - "server_mesh_personal", "smtp_mesh_personal"}) { - t.Fatalf("%+v", u.Each) - } - placed := f.read(profileDir + "/user.js") - for name := range userPrefLines(t, placed) { - if key := strings.Split(name, ".")[2]; !strings.HasSuffix(key, "mesh_personal") { - t.Errorf("a created account's preference outside the module's keys: %s", name) - } - if strings.Contains(strings.ToLower(name), "password") || strings.Contains(name, "directory") { - t.Errorf("the module writes no credential and none of Betterbird's own preferences: %s", name) - } - } - for _, want := range []string{`user_pref("mail.server.server_mesh_personal.port", 993);`, - `user_pref("mail.server.server_mesh_personal.socketType", 3);`, - `user_pref("mail.smtpserver.smtp_mesh_personal.try_ssl", 2);`, - `user_pref("mail.identity.id_mesh_personal.useremail", "me@mail.example.org");`} { - if !strings.Contains(placed, want) { - t.Errorf("the user.js lacks %s:\n%s", want, placed) - } - } - - // Quit: the list gains the module's keys at its end, every other line as it was. - before := f.read(profileDir + "/prefs.js") - os.RemoveAll(filepath.Join(f.Root, "proc", "4000")) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - after := f.read(profileDir + "/prefs.js") - if !strings.Contains(after, `user_pref("mail.accountmanager.accounts", "account1,account3,account2,account_mesh_personal");`) || - !strings.Contains(after, `user_pref("mail.smtpservers", "smtp1,smtp_mesh_personal");`) { - t.Fatalf("the lists:\n%s", after) - } - bl, al := strings.Split(before, "\n"), strings.Split(after, "\n") - if len(bl) != len(al) { - t.Fatalf("lines were added or lost: %d then %d", len(bl), len(al)) - } - for i := range bl { - if bl[i] != al[i] && !strings.Contains(bl[i], "mail.accountmanager.accounts") && !strings.Contains(bl[i], "mail.smtpservers") { - t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i]) - } - } - - // The record lost: an account under the module's own keys is still its creation, never adopted. - os.Remove(filepath.Join(f.Root, testHome, recordFile)) - u, err = f.userJS(true) - if err != nil || u.Each[0].How != created { - t.Fatalf("without the record, the module's keys still say created: %+v %v", u.Each, err) - } - - // The account leaves the setting: only the module's keys go, the mail stays in the profile. - f.declareAccounts(`{"accounts": {}}`) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - if got := f.read(profileDir + "/prefs.js"); got != before { - t.Fatalf("removing the module's keys left the file otherwise as it was:\n%s", got) - } -} - -// adoptable is a declaration of the profile's account1 (other@mail.example.org): equal but for its outgoing -// port, said explicitly, and its incoming user. -const adoptable = `{"accounts": { - "mail": {"address": "Other@mail.example.org", - "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "oauth", "user": "other2"}, - "outgoing": {"host": "smtp.example.org", "port": 465, "security": "tls", "sign-in": "oauth", "user": "other"}}}}` - -func TestADeclaredAccountTheProfileHoldsIsAdoptedUnderItsOwnKeysAndTheListIsLeftAlone(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(adoptable) - os.RemoveAll(filepath.Join(f.Root, "proc", "4000")) - before := f.read(profileDir + "/prefs.js") - u, err := f.userJS(true) - if err != nil { - t.Fatal(err) - } - if len(u.Each) != 1 || u.Each[0].How != adopted || u.Each[0].Keys != (keySet{"account1", "id1", "server1", "smtp1"}) { - t.Fatalf("adopted under the profile's keys: %+v", u.Each) - } - // The address's case and the user name differ; the default port declared explicitly is no change. Names - // only, no value. - if strings.Join(u.Each[0].Changed, ",") != "mail.identity.id1.useremail,mail.server.server1.userName" { - t.Fatalf("the changed preferences, by name: %v", u.Each[0].Changed) - } - if f.read(profileDir+"/prefs.js") != before || u.Lists != "unchanged" { - t.Fatalf("an adopted account's keys are in the lists already: nothing written to prefs.js (%s)", u.Lists) - } - lines := userPrefLines(t, f.read(profileDir+"/user.js")) - for name := range lines { - key := strings.Split(name, ".")[2] - if key != "account1" && key != "id1" && key != "server1" && key != "smtp1" { - t.Errorf("an adopted account's preference outside its keys: %s", name) - } - for _, never := range []string{"directory", ".type", "smtpServer", "identities", ".server"} { - if strings.HasSuffix(name, never) { - t.Errorf("the module wrote %s, which is Betterbird's or the account's own", name) - } - } - } - if !strings.Contains(lines["mail.server.server1.userName"], `"other2"`) { - t.Fatalf("the declared value wins at every start: %v", lines) - } - // The status says it by place and keys, never by address, host or name. - s, err := f.Status() - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(s.Accounts) - if s.Accounts.InList != 1 || s.Accounts.Others != 1 || strings.Contains(string(raw), "example") || - strings.Contains(string(raw), `"mail"`) || !strings.Contains(string(raw), `"account1"`) { - t.Fatalf("%s", raw) - } - - // Leaving the setting releases it: its lines and its record go, the account and the lists stay. - f.declareAccounts(`{"accounts": {}}`) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - if f.read(profileDir+"/prefs.js") != before || strings.Contains(f.read(profileDir+"/user.js"), "server1") || - strings.Contains(f.read(testHome+"/"+recordFile), "account1") { - t.Fatal("a released account stays the person's, with no line or record of the module's") - } -} - -func TestAnAmbiguousOwlsOrOtherKindOfMatchIsRefusedForThatAccountAlone(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - os.RemoveAll(filepath.Join(f.Root, "proc", "4000")) - // A second account with account1's address. - prefs := f.read(profileDir + "/prefs.js") - prefs = strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account4"`, 1) + - `user_pref("mail.account.account4.server", "server4");` + "\n" + - `user_pref("mail.account.account4.identities", "id4");` + "\n" + - `user_pref("mail.identity.id4.useremail", "other@mail.example.org");` + "\n" + - `user_pref("mail.identity.id4.smtpServer", "smtp4");` + "\n" + - `user_pref("mail.smtpserver.smtp4.hostname", "smtp.example.org");` + "\n" + - `user_pref("mail.server.server4.type", "imap");` + "\n" - f.write(profileDir+"/prefs.js", prefs) - both := strings.Replace(adoptable, `{"accounts": {`, `{"accounts": { - "zz": {"address": "new@mail.example.org", - "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"}, - "outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},`, 1) - f.declareAccounts(both) - err := f.Prepare() - if err == nil || !strings.Contains(err.Error(), "declared account 1 of 2: 2 accounts in the profile have its address (account1, account4)") || - !strings.Contains(err.Error(), "key") || strings.Contains(err.Error(), "example.org") { - t.Fatalf("refused by place, with the matches' keys and the remedy, never the address: %v", err) - } - u, _ := f.userJS(false) - if u.Each[0].How != refused || u.Each[1].How != created { - t.Fatalf("the other declared account goes ahead: %+v", u.Each) - } - if strings.Contains(f.read(profileDir+"/user.js"), "server1") || strings.Contains(f.read(profileDir+"/user.js"), "server4") { - t.Fatal("a refused account gets no line") - } - - // The node's layer names the key: that one is adopted. - f.declareAccounts(strings.Replace(both, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1)) - if err := f.Prepare(); err != nil { - t.Fatalf("%v", err) - } - if u, _ := f.userJS(false); u.Each[0].How != adopted || u.Each[0].Keys.Account != "account4" { - t.Fatalf("%+v", u.Each) - } - os.Remove(filepath.Join(f.Root, testHome, recordFile)) - for key, want := range map[string]string{"account9": "not in the profile's account list", "account3": "Owl's", - "account2": "another address"} { - f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "`+key+`",`, 1)) - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("key %s: %v", key, err) - } - } - - // Owl's account at the address, and an account of another kind than declared. - f.declareAccounts(`{"accounts": {"work": {"address": "work@corp.example.com", - "incoming": {"kind": "imap", "host": "outlook.example.com", "security": "tls", "sign-in": "oauth"}, - "outgoing": {"host": "smtp.example.com", "security": "tls", "sign-in": "oauth"}}}}`) - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "Owl's") { - t.Errorf("Owl's account is never adopted: %v", err) - } - f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.server.server4.type", "imap"`, `"mail.server.server4.type", "pop3"`, 1)) - f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1)) - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "never converts") { - t.Errorf("an account of another kind is refused, not converted: %v", err) - } -} - -func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - links(t, nil) - f.declareAccounts(`{"accounts": { - "personal": {"address": "me@mail.example.org", "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"}, - "outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}}, - "google": {"address": "me@gmail.com", "incoming": {"kind": "imap", "host": "imap.gmail.com", "security": "tls", "sign-in": "oauth"}, - "outgoing": {"host": "smtp.gmail.com", "security": "tls", "sign-in": "oauth"}}, - "lan": {"address": "me@lan.example.org", "incoming": {"kind": "pop3", "host": "pop.example.org", "security": "tls", "sign-in": "kerberos"}, - "outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "none"}}}}`) - // The username and password fields are encrypted and never read; a fixture holding junk there proves it. - f.write(profileDir+"/logins.json", `{"logins": [ - {"hostname": "imap://imap.example.org", "httpRealm": "imap://imap.example.org", "encryptedUsername": "MEIE...", "encryptedPassword": "MDoE..."}, - {"hostname": "smtp://smtp.example.org", "encryptedUsername": "x", "encryptedPassword": "y"}]}`) - c := f.countAccounts(filepath.Join(f.Root, profileDir), "~/.thunderbird/60016jsp.default-default") - if c.Declared != 3 || c.WithoutSignIn != 1 || c.Others != 2 || c.InList != 0 { - t.Fatalf("three declared, the OAuth one not signed in, the person's and Owl's other (local folders not counted): %+v", c) - } - raw, _ := json.Marshal(c) - if strings.Contains(string(raw), "example") || strings.Contains(string(raw), "gmail") { - t.Fatalf("a count names nothing: %s", raw) - } -} - -// ADR 0329 §1, §10, §12, §13: betterbird_accounts lists the accounts with their values, Owl's by name and address -// and marked; its propose carries the mesh-wide layer as it stands with accounts in it, and answers the value. -func TestBetterbirdAccountsListsTheValuesAndProposesThemInTheLayerAsItStands(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"imap://imap.example.org","encryptedUsername":"MDoEEPgAAAAA","encryptedPassword":"hunter2secret"}]}`) - links(t, nil) - a, err := f.ProfileAccounts(false) - if err != nil { - t.Fatal(err) - } - if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" { - t.Fatalf("%+v", a) - } - raw, _ := json.Marshal(credentialChecked(a)) - for _, shown := range []string{"other@mail.example.org", "imap.example.org", "smtp.example.org", `"port":993`, `"work@corp.example.com"`} { - if !strings.Contains(string(raw), shown) { - t.Errorf("the answer shows the operator's own %s (ADR 0329 §1): %s", shown, raw) - } - } - for _, never := range []string{"hunter2", "MDoEEP"} { - if strings.Contains(string(raw), never) { - t.Errorf("the answer carries the login store's %q", never) - } - } - for _, l := range a.Accounts { - if l.Key == "account3" && (!l.Owl || l.Value != nil || l.Address != "work@corp.example.com") { - t.Errorf("Owl's account is listed by name and address, marked, never as a value to declare: %+v", l) - } - } - - var asked []map[string]any - asker = func(key string, body any) (json.RawMessage, error) { - if key != "seat:mesh-controller.settings" { - t.Fatalf("asked %s", key) - } - args := body.(map[string]any) - asked = append(asked, args) - if args["propose"] == nil { - return json.RawMessage(`{"ok": true, "output": "{\n \"tags\": {\"$label1\": {\"name\": \"Important\"}}\n}\n approved by the operator\nbetterbird on the whole mesh, every value"}`), nil - } - return json.RawMessage(`{"ok": true, "output": "proposed p-17 for betterbird on the whole mesh: accounts (other@mail.example.org); fingerprint ab12"}`), nil - } - t.Cleanup(func() { asker = nil }) - a, err = f.ProfileAccounts(true) - if err != nil { - t.Fatal(err) - } - if len(asked) != 2 || asked[1]["module"] != "betterbird" || asked[1]["propose"] != "true" || asked[1]["node"] != nil || - asked[1]["replace"] != nil { - t.Fatalf("the layer read, then proposed for the whole mesh, never set, never removing: %v", asked) - } - var layer map[string]json.RawMessage - if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &layer); err != nil || string(layer["tags"]) == "" { - t.Fatalf("the proposal carries the layer as it stands: %v %s", err, asked[1]["values"]) - } - value, err := parseAccounts([]byte(asked[1]["values"].(string))) - if err != nil { - t.Fatalf("the proposed value is one the module takes: %v", err) - } - other, ok := value["mail"] - if len(value) != 1 || !ok || other.Address != "other@mail.example.org" || other.Incoming.SignIn != "oauth" || - other.Incoming.Port != 993 || other.Outgoing.Security != "tls" || other.Outgoing.Port != 465 { - t.Fatalf("the value holds the person's account and not Owl's: %+v", value) - } - if a.Derived["mail"].Address != "other@mail.example.org" { - t.Fatalf("the answer shows the value it proposed (ADR 0329 §10): %+v", a.Derived) - } - if strings.Contains(a.Proposed, "other@") || !strings.Contains(a.Proposed, "p-17") { - t.Fatalf("the controller's words come back with every address masked: %s", a.Proposed) - } -} - -func TestAnAdoptedAccountNeverWritesIntoAnOutgoingServerAnotherIdentityUses(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - os.RemoveAll(filepath.Join(f.Root, "proc", "4000")) - // A second account of the person's, not declared, sends through smtp1 too. - prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"account1,account3,account2"`, `"account1,account3,account2,account5"`, 1) + - `user_pref("mail.account.account5.server", "server5");` + "\n" + - `user_pref("mail.account.account5.identities", "id5");` + "\n" + - `user_pref("mail.identity.id5.useremail", "fifth@mail.example.org");` + "\n" + - `user_pref("mail.identity.id5.smtpServer", "smtp1");` + "\n" + - `user_pref("mail.server.server5.type", "imap");` + "\n" - f.write(profileDir+"/prefs.js", prefs) - f.declareAccounts(adoptable) - err := f.Prepare() - if err == nil || !strings.Contains(err.Error(), "also used by identity id5") || strings.Contains(err.Error(), "example.org") { - t.Fatalf("refused with the remedy, by keys: %v", err) - } - if user, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); strings.Contains(string(user), "smtpserver.smtp1") { - t.Fatal("the shared outgoing server was written") - } - - // Recorded adoption, then the person moves the identity to a server of its own: the module follows it. - f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.identity.id5.smtpServer", "smtp1"`, `"mail.identity.id5.smtpServer", "smtp5"`, 1)) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - moved := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.identity.id1.smtpServer", "smtp1"`, `"mail.identity.id1.smtpServer", "smtp9"`, 1) + - `user_pref("mail.smtpserver.smtp9.hostname", "smtp.example.org");` + "\n" - f.write(profileDir+"/prefs.js", moved) - u, err := f.userJS(true) - if err != nil || u.Each[0].How != adopted || u.Each[0].Keys.SMTP != "smtp9" { - t.Fatalf("the identity's current outgoing server is followed: %+v %v", u.Each, err) - } - user := f.read(profileDir + "/user.js") - if strings.Contains(user, "smtpserver.smtp1.") || !strings.Contains(user, "smtpserver.smtp9.hostname") { - t.Fatalf("never the old one:\n%s", user) - } - // And the move back onto a shared server is refused again. - f.write(profileDir+"/prefs.js", strings.Replace(moved, `"mail.identity.id5.smtpServer", "smtp5"`, `"mail.identity.id5.smtpServer", "smtp9"`, 1)) - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id5") { - t.Fatalf("%v", err) - } -} - -func TestTheUserJSAnswerWithholdsTheAccountsValues(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare(`user_pref("mail.compose.default_to", "x");`) - f.declareAccounts(personal) - u, err := f.userJS(false) - if err != nil { - t.Fatal(err) - } - for _, never := range []string{"example.org", `"me"`, `"Me"`} { - if strings.Contains(u.Content, never) { - t.Errorf("the answer carries %s:\n%s", never, u.Content) - } - } - if !strings.Contains(u.Content, `user_pref("mail.server.server_mesh_personal.hostname", "");`) { - t.Fatalf("an account's preference is named and its value withheld:\n%s", u.Content) - } - // What is placed in the profile keeps the values: only the answer withholds them. - if f.read(testHome+"/"+accountsFile) == "" { - t.Fatal("no declaration") - } -} - -func TestACredentialRefusalNamesNoAccount(t *testing.T) { - _, err := parseAccounts([]byte(`{"accounts": {"personal": {"incoming": {"password": "hunter2"}}}}`)) - if err == nil || strings.Contains(err.Error(), "personal") || !strings.Contains(err.Error(), "accounts..incoming.password") { - t.Fatalf("%v", err) - } -} - -func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - os.RemoveAll(filepath.Join(f.Root, "proc", "4000")) - // account1 has an alias identity on smtp1; smtp1 is the default server; Owl's account and local folders - // have identities that name no outgoing server. - prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.account.account1.identities", "id1"`, `"mail.account.account1.identities", "id1,id1b"`, 1) - prefs = strings.Replace(prefs, `"mail.account.account2.server", "server2");`, `"mail.account.account2.server", "server2");`+"\n"+ - `user_pref("mail.account.account2.identities", "id2");`, 1) + - `user_pref("mail.identity.id1b.useremail", "alias@mail.example.org");` + "\n" + - `user_pref("mail.identity.id1b.smtpServer", "smtp1");` + "\n" + - `user_pref("mail.identity.id2.useremail", "local@mail.example.org");` + "\n" + - `user_pref("mail.smtp.defaultserver", "smtp1");` + "\n" - f.write(profileDir+"/prefs.js", prefs) - f.declareAccounts(adoptable) - if err := f.Prepare(); err != nil { - t.Fatalf("an account's own alias, and identities that do not send by SMTP, are no sharing: %v", err) - } - // An identity of another mail account naming no server does use the default one. - f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account6"`, 1)+ - `user_pref("mail.account.account6.server", "server6");`+"\n"+ - `user_pref("mail.account.account6.identities", "id6");`+"\n"+ - `user_pref("mail.identity.id6.useremail", "six@mail.example.org");`+"\n"+ - `user_pref("mail.server.server6.type", "pop3");`+"\n") - os.Remove(filepath.Join(f.Root, testHome, recordFile)) - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id6") { - t.Fatalf("%v", err) - } -} - -// Review 121: the account lists wait for Betterbird to close by its profile's lock too, not only by its process. -func TestTheAccountListsWaitWhileTheProfileIsLocked(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(personal) - stopBetterbird(t, f) - f.write(profileDir+"/lock", "") - before := f.read(profileDir + "/prefs.js") - u, err := f.userJS(true) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "locked") || f.read(profileDir+"/prefs.js") != before { - t.Fatalf("with the profile locked and no Betterbird running, prefs.js is unchanged: %q", u.Lists) - } - os.Remove(filepath.Join(f.Root, profileDir, "lock")) - if u, err = f.userJS(true); err != nil || !strings.HasPrefix(u.Lists, "written") { - t.Fatalf("once unlocked the list is written: %q %v", u.Lists, err) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/adoption.go b/modules/betterbird/cmd/betterbird-tools/adoption.go deleted file mode 100644 index 02adc852..00000000 --- a/modules/betterbird/cmd/betterbird-tools/adoption.go +++ /dev/null @@ -1,331 +0,0 @@ -package main - -// Which account of the profile a declared account is (novox/hq ADR 0317): adopted, created or refused. -// -// At every apply, per declared account in its place in the setting (§1): -// -// 1. an account the module recorded on this node for this profile, adopted or created, is kept by its -// recorded keys without matching again, while its account key is still in the profile's account list; -// 2. otherwise an account under the module's own mesh_ keys is the module's creation; -// 3. otherwise a node's layer may name the key to adopt (§8): adopted only when it is in the account list, -// its default identity has the declared address and it is not Owl's; -// 4. otherwise the profile's accounts whose default identity has the declared address: one is adopted, none -// means the account is created under mesh_ keys (ADR 0316 §11). -// -// Refused, with the remedy, for that account alone (§7): two or more accounts with the address, the one that -// has it being Owl's, a named key that does not qualify, an account of another kind than the declaration says -// (the module never converts one: the review of ADR 0317), and an identity that names no outgoing server. The -// other declared accounts go ahead. -// -// Adopting takes the account's keys as the module's (§2): its account key, its default identity, its incoming -// server and the outgoing server that identity names. The account lists are left as they are, because the keys -// are in them already; the declared fields are written under those keys (§4, §5). What was adopted or created -// is recorded in the module's directory in the home, by the tools and never by a setting (§3); leaving the -// setting releases an adopted account (its lines and its record go, the account stays the person's, §6). - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// recordFile is where the module records, per profile, what it adopted or created (ADR 0317 §3). -const recordFile = ".config/betterbird/accounts-record.json" - -// How a declared account resolved. -const ( - adopted = "adopted" - created = "created" - refused = "refused" -) - -// keySet are an account's four keys. -type keySet struct { - Account string `json:"account"` - Identity string `json:"identity"` - Server string `json:"server"` - SMTP string `json:"smtp"` -} - -// resolvedAccount is one declared account as it resolved, said by its place and keys, never its name, address -// or host (ADR 0316 §16, ADR 0317 §3). -type resolvedAccount struct { - Place int `json:"place"` - How string `json:"how"` - Keys keySet `json:"keys,omitempty"` - // Why is a refusal's reason and its remedy. - Why string `json:"why,omitempty"` - // Changed are the preferences an apply changed for it, by name only (ADR 0317 §4). - Changed []string `json:"changed,omitempty"` - name string -} - -// record is what the module adopted or created, per profile (its directory as the tools say it), per -// declared account's name. -type record map[string]map[string]recorded - -type recorded struct { - How string `json:"how"` - Keys keySet `json:"keys"` -} - -func (m *Machine) readRecord(profileDir string) map[string]recorded { - raw, err := readBounded(m.home(recordFile)) - if err != nil { - return map[string]recorded{} - } - var r record - if json.Unmarshal(raw, &r) != nil || r[profileDir] == nil { - return map[string]recorded{} - } - return r[profileDir] -} - -// writeRecord keeps what this apply resolved for the profile: a refused account is not recorded, and an -// account no longer declared is dropped (§6). -func (m *Machine) writeRecord(profileDir string, resolved []resolvedAccount) error { - r := record{} - if raw, err := readBounded(m.home(recordFile)); err == nil { - _ = json.Unmarshal(raw, &r) - } - mine := map[string]recorded{} - for _, a := range resolved { - if a.How != refused { - mine[a.name] = recorded{How: a.How, Keys: a.Keys} - } - } - if len(mine) == 0 { - delete(r, profileDir) - } else { - r[profileDir] = mine - } - body, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err - } - path := m.home(recordFile) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - tmp := path + ".mesh-new" - if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil { - return err - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return err - } - return nil -} - -// resolveAccounts resolves every declared account, in its place in the setting (the file's order, by name). -func resolveAccounts(accounts map[string]Account, prefs map[string]string, rec map[string]recorded) []resolvedAccount { - have := profileAccounts(prefs) - inList := map[string]profileAccount{} - for _, a := range have { - inList[a.Key] = a - } - names := sortedNames(accounts) - taken := map[string]bool{} - var out []resolvedAccount - for i, name := range names { - r := resolveOne(i+1, len(names), name, accounts[name], prefs, have, inList, rec, taken) - r.name = name - if r.How != refused { - taken[r.Keys.Account] = true - } - out = append(out, r) - } - return refuseSharedOutgoing(out, prefs, have) -} - -// refuseSharedOutgoing refuses an adopted account whose outgoing server another identity uses that is neither -// adopted nor the module's (review 34 of mesh-catalog #232): Betterbird lets identities share an outgoing -// server, and the declared values written into it would change that other account's sending at every start. -// ADR 0317 §2 adopts the outgoing server the identity names and leaves the lists as they are, so the module -// does not give the identity a server of its own; it refuses, with the remedy. -func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, have []profileAccount) []resolvedAccount { - // Every identity of an adopted or created account is its own, its aliases too (review 36). - accountOf := map[string]profileAccount{} - kindOf := map[string]string{} - for _, a := range have { - accountOf[a.Key] = a - for _, id := range a.Identities { - kindOf[id] = a.Kind - } - } - ours := map[string]bool{} - for _, r := range resolved { - if r.How == refused { - continue - } - ours[r.Keys.Identity] = true - for _, id := range accountOf[r.Keys.Account].Identities { - ours[id] = true - } - } - defaultServer := unquote(prefs["mail.smtp.defaultserver"]) - users := map[string][]string{} - seen := map[string]bool{} - use := func(id string) { - // An identity the module created for a declared account sends as that account does (ADR 0329 §7). - if seen[id] || ours[id] || ownIdentity.MatchString(id) { - return - } - seen[id] = true - server := unquote(prefs["mail.identity."+id+".smtpServer"]) - // Only a mail account's identity sends through the default server when it names none: Owl's, local - // folders and feeds do not send by SMTP (review 36). - if server == "" && (kindOf[id] == "imap" || kindOf[id] == "pop3") { - server = defaultServer - } - if server != "" { - users[server] = append(users[server], id) - } - } - for _, a := range have { - for _, id := range a.Identities { - use(id) - } - } - for k := range prefs { - if strings.HasPrefix(k, "mail.identity.") && strings.HasSuffix(k, ".smtpServer") { - use(strings.TrimSuffix(strings.TrimPrefix(k, "mail.identity."), ".smtpServer")) - } - } - for i, r := range resolved { - if r.How != adopted { - continue - } - if others := users[r.Keys.SMTP]; len(others) > 0 { - sort.Strings(others) - resolved[i] = resolvedAccount{Place: r.Place, How: refused, name: r.name, Why: fmt.Sprintf( - "declared account %d of %d: the outgoing server %s of the profile's account %s is also used by "+ - "identity %s, whose sending the declared values would change: give the account an outgoing "+ - "server of its own in Betterbird's account settings, then start Betterbird again", - r.Place, len(resolved), r.Keys.SMTP, r.Keys.Account, strings.Join(others, ", "))} - } - } - return resolved -} - -func resolveOne(place, of int, name string, a Account, prefs map[string]string, have []profileAccount, - inList map[string]profileAccount, rec map[string]recorded, taken map[string]bool) resolvedAccount { - r := resolvedAccount{Place: place} - refuse := func(format string, args ...any) resolvedAccount { - r.How, r.Keys = refused, keySet{} - r.Why = fmt.Sprintf("declared account %d of %d: ", place, of) + fmt.Sprintf(format, args...) - return r - } - // 1. Recorded, while its account is still in the list. - if was, ok := rec[name]; ok { - if _, there := inList[was.Keys.Account]; there || was.How == created { - r.How, r.Keys = was.How, was.Keys - if was.How == adopted { - // The identity's outgoing server as it is now: a person may have moved it since. - smtpKey := unquote(prefs["mail.identity."+was.Keys.Identity+".smtpServer"]) - if smtpKey == "" { - return refuse("the profile's account %s has an identity that names no outgoing server: choose one "+ - "for it in Betterbird's account settings, then start Betterbird again", was.Keys.Account) - } - r.Keys.SMTP = smtpKey - } - return r - } - } - // 2. Under the module's own keys: its creation. - account, identity, server, smtp := keysOf(name) - own := keySet{account, identity, server, smtp} - if _, there := inList[account]; there { - r.How, r.Keys = created, own - return r - } - adopt := func(pa profileAccount) resolvedAccount { - if pa.Kind != a.Incoming.Kind { - return refuse("the profile's account %s is of kind %s and the declaration says %s: the module never "+ - "converts an account. Declare the kind the account has, or remove the account in Betterbird", - pa.Key, pa.Kind, a.Incoming.Kind) - } - smtpKey := unquote(prefs["mail.identity."+pa.Identities[0]+".smtpServer"]) - if smtpKey == "" { - return refuse("the profile's account %s has an identity that names no outgoing server: choose one for it "+ - "in Betterbird's account settings, then start Betterbird again", pa.Key) - } - r.How, r.Keys = adopted, keySet{pa.Key, pa.Identities[0], pa.Server, smtpKey} - return r - } - matchesDefault := func(pa profileAccount) bool { - return len(pa.Addresses) > 0 && strings.EqualFold(pa.Addresses[0], a.Address) - } - // 3. The key a node's layer names. - if a.Key != "" { - pa, there := inList[a.Key] - switch { - case !there: - return refuse("the key %s this node's layer names is not in the profile's account list", a.Key) - case pa.Owl: - return refuse("the key %s this node's layer names is Owl's account, which is never declared "+ - "(novox/hq ADR 0316 §13)", a.Key) - case !matchesDefault(pa): - return refuse("the key %s this node's layer names is an account whose default identity has another "+ - "address", a.Key) - case taken[pa.Key]: - return refuse("the key %s is already another declared account's", a.Key) - } - return adopt(pa) - } - // 4. By the default identity's address. - var matches []profileAccount - for _, pa := range have { - if !pa.Ours && !taken[pa.Key] && matchesDefault(pa) { - matches = append(matches, pa) - } - } - switch { - case len(matches) == 0: - r.How, r.Keys = created, own - return r - case len(matches) > 1: - var keys []string - for _, pa := range matches { - keys = append(keys, pa.Key) - } - return refuse("%d accounts in the profile have its address (%s): remove the duplicate in Betterbird, or "+ - "give the one to adopt as key in this node's layer of the setting accounts (novox/hq ADR 0317 §7, §8)", - len(matches), strings.Join(keys, ", ")) - case matches[0].Owl: - return refuse("the profile's account with its address, %s, is Owl's, which is never declared (novox/hq "+ - "ADR 0316 §13): take it out of the setting accounts", matches[0].Key) - } - return adopt(matches[0]) -} - -// changedPrefs are the preferences of a resolved account whose declared value differs from the profile's -// saved one (prefs.js), by name only (ADR 0317 §4). A preference prefs.js does not hold is changed unless the -// declared value is what Betterbird reads in its place. -func changedPrefs(r resolvedAccount, a Account, saved map[string]string) []string { - defaults := map[string]string{} - if r.How == adopted { - // What Betterbird reads when prefs.js holds no value: the same defaults betterbird_accounts reads with. - pa := profileAccount{Key: r.Keys.Account, Server: r.Keys.Server, Kind: a.Incoming.Kind, - Identities: []string{r.Keys.Identity}} - view := readAccount(saved, pa) - for _, p := range declaredPrefs(r.Keys, view, true) { - defaults[p[0]] = p[1] - } - } - var out []string - for _, p := range declaredPrefs(r.Keys, a, r.How == adopted) { - have, ok := saved[p[0]] - if !ok { - have, ok = defaults[p[0]] - } - if !ok || have != p[1] { - out = append(out, p[0]) - } - } - return out -} diff --git a/modules/betterbird/cmd/betterbird-tools/args.go b/modules/betterbird/cmd/betterbird-tools/args.go deleted file mode 100644 index 9b5dfcf7..00000000 --- a/modules/betterbird/cmd/betterbird-tools/args.go +++ /dev/null @@ -1,97 +0,0 @@ -// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. -// The same in every desktop module that carries it. -package main - -import ( - "fmt" - "math" - "strings" - "time" -) - -// text is a string argument, trimmed; required says an empty one is refused. -func text(args map[string]any, key string, required bool) (string, error) { - v, present := args[key] - if !present || v == nil { - if required { - return "", fmt.Errorf("%s is required", key) - } - return "", nil - } - s, ok := v.(string) - if !ok { - return "", fmt.Errorf("%s is a string, not %T", key, v) - } - s = strings.TrimSpace(s) - if s == "" && required { - return "", fmt.Errorf("%s is required", key) - } - return s, nil -} - -// whole is a whole-number argument within [least, most], or def when absent. -func whole(args map[string]any, key string, def, least, most int) (int, error) { - v, present := args[key] - if !present || v == nil { - return def, nil - } - f, ok := v.(float64) - if !ok { - if i, isInt := v.(int); isInt { - f = float64(i) - } else { - return 0, fmt.Errorf("%s is a number, not %T", key, v) - } - } - if f != math.Trunc(f) { - return 0, fmt.Errorf("%s is a whole number, not %v", key, f) - } - n := int(f) - if n < least || n > most { - return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) - } - return n, nil -} - -// flag is a boolean argument, or def when absent. -func flag(args map[string]any, key string, def bool) (bool, error) { - v, present := args[key] - if !present || v == nil { - return def, nil - } - b, ok := v.(bool) - if !ok { - return false, fmt.Errorf("%s is true or false, not %T", key, v) - } - return b, nil -} - -// texts is a list-of-strings argument. -func texts(args map[string]any, key string) ([]string, error) { - v, present := args[key] - if !present || v == nil { - return nil, nil - } - list, ok := v.([]any) - if !ok { - if ss, isStrings := v.([]string); isStrings { - return ss, nil - } - return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) - } - out := make([]string, 0, len(list)) - for i, item := range list { - s, ok := item.(string) - if !ok { - return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) - } - out = append(out, s) - } - return out, nil -} - -// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. -func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { - n, err := whole(args, key, def, 1, most) - return time.Duration(n) * time.Second, err -} diff --git a/modules/betterbird/cmd/betterbird-tools/betterbird.go b/modules/betterbird/cmd/betterbird-tools/betterbird.go deleted file mode 100644 index a76148e3..00000000 --- a/modules/betterbird/cmd/betterbird-tools/betterbird.go +++ /dev/null @@ -1,1066 +0,0 @@ -package main - -// Betterbird as the tools see it. Betterbird is the mail client's one process tree: the main process -// (comm betterbird-bin, no -contentproc) owns every window — the mail window, the calendar's reminder -// dialog, the composer — and its children (-contentproc) render add-ons and media. The tools read only: -// - the account's processes, and of the main process where its output goes (fd 1 and 2) and which -// profile it holds open; -// - profiles.ini, to find the profile, and the profile's prefs.js and user.js, of which only the -// preferences asked for are answered and never one that can hold a credential or names an account -// (passwordSecret, accountPrefix); -// - the profile's extensions.json, for the add-ons' ids and whether each is on; -// - the module's settings as the node-engine writes them: prefs (~/.config/betterbird/prefs.json, prefs.go), -// accounts (~/.config/betterbird/accounts.json, accounts.go), and identities, calendars, address-books, -// filters and tags (configuration.json and tags.json, configuration.go); -// - of logins.json, only the server name each kept sign-in is for, which Betterbird keeps in the clear, -// to count the declared accounts that have not signed in yet (novox/hq ADR 0316 §16). -// -// Never read: the mail store, the address books' contacts, a user name or password in logins.json, key4.db, -// cert9.db, cookies, the calendar's events and caches. The operator's own configuration is answered with its -// values (novox/hq ADR 0329 §1), never a credential (§2). -// -// The files a tool writes in the profile are user.js, and only the module's own (betterbird_user_js with apply, -// betterbird_restart, and the start from the launcher); and, only while Betterbird is not running, the module's -// own keys in the lists of prefs.js, the removals of what it created, and each declared account's -// msgFilterRules.dat (accounts.go, owned.go). A user.js that is not the module's is never overwritten. - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" -) - -const ( - mainComm = "betterbird-bin" - // startBin is Betterbird itself, where the mesh's release and the community package both put it. The - // module's start (/usr/local/bin/betterbird) runs the preparation first; a tool that starts Betterbird - // prepares itself and starts this. - startBin = installDir + "/betterbird" - // packageFor is the community package the mesh's release replaces (novox/hq ADR 0316 §4). - packageFor = "betterbird-bin" - appIni = "/opt/betterbird/application.ini" - restartAs = "mesh-betterbird" - testUnit = "mesh-betterbird-reminder-test" - // profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares. - profilesDir = ".thunderbird" - // mark is the first line of the module's user.js: how a user.js is known to be the module's. - mark = "// Betterbird's user.js (module betterbird" -) - -// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link. -var readLink = os.Readlink - -// mainProcs are Betterbird's main processes: comm betterbird-bin without -contentproc. -func (m *Machine) mainProcs() []Proc { - var out []Proc - for _, p := range m.procs(mainComm) { - // 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) - } - } - return out -} - -// Stream is where a process's standard output or error goes. -type Stream struct { - FD int `json:"fd"` - Goes string `json:"goes"` - // Dead is a pipe no process of the account reads: a write there fails. - Dead bool `json:"dead,omitempty"` -} - -// streams says where fd 1 and 2 of a process go. -func (m *Machine) streams(pid int) []Stream { - var out []Stream - for _, fd := range []int{1, 2} { - target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd))) - s := Stream{FD: fd} - switch { - case err != nil: - s.Goes = "unreadable" - case target == "/dev/null": - s.Goes = "discarded (/dev/null)" - case strings.HasPrefix(target, "socket:"): - s.Goes = "a socket (the journal, when started by the module)" - case strings.HasPrefix(target, "pipe:"): - if r := m.pipeReaders(target, pid); len(r) > 0 { - s.Goes = "a pipe read by " + strings.Join(r, ", ") - } else { - s.Goes, s.Dead = "a pipe nobody reads", true - } - default: - s.Goes = m.tilde(target) - } - out = append(out, s) - } - return out -} - -// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo). -func (m *Machine) pipeReaders(pipe string, except int) []string { - entries, _ := os.ReadDir(m.path("/proc")) - seen := map[string]bool{} - var out []string - for _, e := range entries { - pid, err := strconv.Atoi(e.Name()) - if err != nil || pid == except { - continue - } - dir := m.path(filepath.Join("/proc", e.Name())) - if m.uidOf(dir) != m.UID { - continue - } - fds, _ := os.ReadDir(filepath.Join(dir, "fd")) - for _, fd := range fds { - if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe { - continue - } - if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) { - name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid) - if !seen[name] { - seen[name] = true - out = append(out, name) - } - } - } - } - sort.Strings(out) - return out -} - -// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading. -func readOnly(fdinfo string) bool { - for _, l := range strings.Split(fdinfo, "\n") { - if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" { - n, err := strconv.ParseInt(f[1], 8, 64) - return err == nil && n&3 == 0 - } - } - return false -} - -// iniSections reads an INI file into its sections, in order. -func iniSections(raw []byte) (names []string, sections map[string]map[string]string) { - sections = map[string]map[string]string{} - cur := "" - s := bufio.NewScanner(bytes.NewReader(raw)) - for s.Scan() { - l := strings.TrimSpace(s.Text()) - switch { - case l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#"): - case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"): - cur = l[1 : len(l)-1] - if _, ok := sections[cur]; !ok { - names = append(names, cur) - sections[cur] = map[string]string{} - } - default: - if i := strings.IndexByte(l, '='); i > 0 && cur != "" { - sections[cur][strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:]) - } - } - } - return names, sections -} - -// Profile is the profile the tools act on, and how it was found. -type Profile struct { - Dir string `json:"dir"` - FoundBy string `json:"found_by"` - path string -} - -// profile finds the profile: the one the running Betterbird holds open, else the installation's -// default in profiles.ini ([Install…] Default), else the profile marked Default=1. -func (m *Machine) profile() (Profile, error) { - base := m.home(profilesDir) - if ps := m.mainProcs(); len(ps) > 0 { - counts := map[string]int{} - fds, _ := os.ReadDir(m.path(fmt.Sprintf("/proc/%d/fd", ps[0].PID))) - for _, fd := range fds { - t, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%s", ps[0].PID, fd.Name()))) - if err != nil { - continue - } - rel, err := filepath.Rel(filepath.Join(m.Home, profilesDir), t) - if err != nil || strings.HasPrefix(rel, "..") || !strings.Contains(rel, "/") { - continue - } - counts[strings.SplitN(rel, "/", 2)[0]]++ - } - best, n := "", 0 - for d, c := range counts { - if c > n || (c == n && d < best) { - best, n = d, c - } - } - if best != "" { - return Profile{Dir: m.tilde(filepath.Join(m.Home, profilesDir, best)), path: filepath.Join(base, best), - FoundBy: fmt.Sprintf("held open by the running Betterbird (pid %d, %d files)", ps[0].PID, n)}, nil - } - } - raw, err := readBounded(filepath.Join(base, "profiles.ini")) - if err != nil { - return Profile{}, fmt.Errorf("no profile: Betterbird is not running and ~/%s/profiles.ini is unreadable (%v)", profilesDir, err) - } - names, sections := iniSections(raw) - pick := func(rel string, by string) (Profile, error) { - dir := filepath.Join(base, filepath.FromSlash(rel)) - if filepath.IsAbs(rel) { - dir = m.path(rel) - } - if !strings.HasPrefix(filepath.Clean(dir), filepath.Clean(base)+string(filepath.Separator)) { - return Profile{}, fmt.Errorf("profiles.ini names a profile outside ~/%s: %s", profilesDir, rel) - } - return Profile{Dir: m.tilde(strings.TrimPrefix(dir, m.Root)), path: dir, FoundBy: by}, nil - } - var installs []string - for _, n := range names { - if strings.HasPrefix(n, "Install") && sections[n]["Default"] != "" { - installs = append(installs, n) - } - } - if len(installs) == 1 { - return pick(sections[installs[0]]["Default"], "profiles.ini: the installation's default ["+installs[0]+"]") - } - for _, n := range names { - if strings.HasPrefix(n, "Profile") && sections[n]["Default"] == "1" && sections[n]["Path"] != "" { - return pick(sections[n]["Path"], "profiles.ini: the profile marked Default=1 ["+n+"]") - } - } - return Profile{}, fmt.Errorf("profiles.ini names %d installation defaults and no profile marked default: start Betterbird once, then ask again", len(installs)) -} - -// prefLine is one user_pref(…) or pref(…) line of prefs.js or user.js. -var prefLine = regexp.MustCompile(`^\s*(?:user_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;\s*$`) - -// readPrefs reads the preferences of a prefs.js or user.js: name to its value, as written. -func readPrefs(raw []byte) map[string]string { - out := map[string]string{} - s := bufio.NewScanner(bytes.NewReader(raw)) - s.Buffer(make([]byte, 64<<10), 4<<20) - for s.Scan() { - if mm := prefLine.FindStringSubmatch(s.Text()); mm != nil { - out[mm[1]] = mm[2] - } - } - return out -} - -// passwordSecret and accountPrefix say which preferences are never answered with their value: one -// whose name can hold a credential, and one that names an account, a server or a person. -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.", - "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} - // mailnewsAccount is a mailnews.* name that holds a server, an account, an identity or the operator's own - // tags: only those are withheld of mailnews.*, so a setting such as mailnews.start_page.enabled or - // mailnews.default_view_flags is answered (novox/hq issue 345). - mailnewsAccount = regexp.MustCompile(`(?i)^mailnews\..*(server|account|ident|smtp|ldap|imap|pop3|nntp|tags\.|email|addr)`) -) - -// accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so -// the setting prefs refuses them and an account has one owner (novox/hq ADR 0316 §11). -var accountOwned = []string{"mail.account.", "mail.identity.", "mail.server.", "mail.smtpserver.", - "mail.accountmanager.", "mail.smtpservers", "mail.smtp.defaultserver"} - -// 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" - } - for _, p := range accountPrefix { - if strings.HasPrefix(name, p) { - return "it names an account, a server, a calendar or a person" - } - } - if mailnewsAccount.MatchString(name) { - return "it names an account, a server, a calendar or a person" - } - return "" -} - -// Pref is one preference as the profile holds it. -type Pref struct { - Name string `json:"name"` - // Value is what Betterbird uses: the policy's lock, else user.js's at the start, else prefs.js's, else the - // default, when a defaults file gives it. - Value string `json:"value,omitempty"` - From string `json:"from,omitempty"` - // Default is the default Betterbird starts with, and the file that gives it: Betterbird's own (omni.ja), or - // the module's (novox/hq issue 345). Absent for a preference compiled into Betterbird. - Default string `json:"default,omitempty"` - DefaultFrom string `json:"default_from,omitempty"` - Withheld string `json:"withheld,omitempty"` -} - -// mostPrefs bounds how many preferences one answer carries. -const mostPrefs = 200 - -// Prefs answers the named preferences (a name, or a prefix ending in . or *) from the profile. -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 - } - prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js")) - userJS, _ := readBounded(filepath.Join(p.path, "user.js")) - fromPrefs, fromUser := readPrefs(prefsJS), statementsOf(userJS) - own, ownErr := m.defaults(true) - locked := m.locks() - all := map[string]bool{} - for n := range fromPrefs { - all[n] = true - } - for n := range fromUser { - all[n] = true - } - var out []Pref - cut := false - seen := map[string]bool{} - for _, want := range names { - prefix := strings.HasSuffix(want, ".") || strings.HasSuffix(want, "*") - want = strings.TrimSuffix(want, "*") - var matched []string - if prefix { - for n := range all { - if strings.HasPrefix(n, want) { - matched = append(matched, n) - } - } - sort.Strings(matched) - } else { - matched = []string{want} - } - for _, n := range matched { - if seen[n] { - continue - } - seen[n] = true - if len(out) == mostPrefs { - cut = true - break - } - pr := Pref{Name: n} - d, hasDefault := own[n] - v, from := fromPrefs[n], "prefs.js" - if l, ok := locked[n]; ok { - v, from = l, "locked by the module's policy" - } else if u, ok := fromUser[n]; ok { - v, from = u, "user.js (wins at the start)" - } else if _, ok := fromPrefs[n]; !ok { - switch { - case hasDefault: - v, from = d.Value, "not set: "+d.From - default: - v, from = "", "not set: Betterbird's built-in default (in no defaults file)" - } - } - pr.From = from - if hasDefault { - pr.DefaultFrom = d.From - } - if why := withheld(n); why != "" { - pr.Withheld = why - } else if allowedExtensions[n] { - pr.Value = v - pr.Default = d.Value - } else { - pr.Value = m.maskValue(v) - pr.Default = m.maskValue(d.Value) - } - out = append(out, pr) - } - } - answer := map[string]any{"profile": p, "prefs": out} - if ownErr != nil { - answer["defaults_problem"] = ownErr.Error() - } - if cut { - answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs) - } - return answer, nil -} - -// statement is the one thing a line of the module's user.js may be, beside a comment. -var statement = regexp.MustCompile(`^user_pref\("[A-Za-z0-9_.\-\[\]]+",\s*(true|false|-?[0-9]+|"(?:[^"\\]|\\.)*")\);$`) - -// validUserJS says what is wrong with a user.js the module would place, or nil. It holds comments and -// user_pref(…) statements only — the setting is one line, so several statements may share one — and -// sets no preference whose name could hold a credential. -func validUserJS(text string) error { - if !strings.HasPrefix(text, mark) { - return fmt.Errorf("it does not begin with the module's mark (%q)", mark) - } - for i, line := range strings.Split(text, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - for _, st := range splitStatements(line) { - if st == "" || strings.HasPrefix(st, "//") { - continue - } - if !statement.MatchString(st) { - return fmt.Errorf("line %d holds %q, which is not user_pref(\"name\", value);", i+1, st) - } - name := st[len(`user_pref("`):strings.Index(st, `",`)] - if passwordSecret.MatchString(name) { - return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name) - } - for _, p := range accountOwned { - if strings.HasPrefix(name, p) { - return fmt.Errorf("line %d sets %s, an account's preference: an account is declared in the setting "+ - "accounts, never in prefs (novox/hq ADR 0316 §11)", i+1, name) - } - } - for _, p := range configurationOwned { - if strings.HasPrefix(name, p) { - return fmt.Errorf("line %d sets %s, which the setting calendars, address-books or tags owns, never "+ - "prefs (novox/hq ADR 0329 §6)", i+1, name) - } - } - } - } - return nil -} - -// splitStatements cuts a line after each `);`, outside a string. -func splitStatements(line string) []string { - var out []string - in, esc, start := false, false, 0 - for i := 0; i < len(line); i++ { - c := line[i] - switch { - case esc: - esc = false - case c == '\\' && in: - esc = true - case c == '"': - in = !in - case c == ';' && !in: - out = append(out, strings.TrimSpace(line[start:i+1])) - start = i + 1 - } - } - if rest := strings.TrimSpace(line[start:]); rest != "" { - out = append(out, rest) - } - return out -} - -// UserJS is the module's user.js against the profile's. -type UserJS struct { - Declared string `json:"declared"` - Present bool `json:"declared_present"` - Valid string `json:"declared_problem,omitempty"` - Profile string `json:"profile_user_js"` - Placed string `json:"placed"` - Statements int `json:"statements"` - // Effective says whether the running Betterbird started after the file was placed. - Effective string `json:"effective,omitempty"` - Content string `json:"content,omitempty"` - Applied bool `json:"applied,omitempty"` - // Accounts are how many accounts the setting accounts declares, and what is wrong with them. - Accounts int `json:"declared_accounts"` - AccountsProblem string `json:"accounts_problem,omitempty"` - // Each is every declared account by its place: adopted, created or refused (with why), its keys, and the - // preferences an apply changed for it, by name (novox/hq ADR 0317 §3, §4). - Each []resolvedAccount `json:"accounts,omitempty"` - // Lists says whether the keys of the accounts the module created were written into the account lists. - Lists string `json:"account_lists,omitempty"` - // Configuration is every declared identity, calendar, address book and tag by its name: adopted, created or - // refused (with why), and its key (novox/hq ADR 0329 §7). - Configuration []resolvedEntry `json:"configuration,omitempty"` - ConfigurationProblem string `json:"configuration_problem,omitempty"` - // Filters are the declared accounts' filter files. - Filters []filterPlan `json:"filters,omitempty"` - // Closed says what was written of what Betterbird rewrites itself, or why it waits (§9). - Closed string `json:"written_while_closed,omitempty"` - // Waiting counts, per kind, the changes that wait for Betterbird to close. - Waiting map[string]int `json:"waiting_for_betterbird_to_close,omitempty"` -} - -// refusedAccounts are the reasons of the declared accounts that were refused. -func (u UserJS) refusedAccounts() []string { - var out []string - for _, r := range u.Each { - if r.How == refused { - out = append(out, r.Why) - } - } - return out -} - -// userJS compares the user.js the module places — the declared file, then the declared accounts' fields under -// the keys each was adopted or created under (adoption.go) — with the profile's; apply places it when the -// profile has none or the module's, records what was adopted or created, and writes the keys of the accounts -// it created into the profile's account lists when Betterbird is not running. A refused account gets nothing; -// the others go ahead (novox/hq ADR 0317 §7). -func (m *Machine) userJS(apply bool) (UserJS, error) { - a := UserJS{Declared: "~/" + prefsFile} - declaredText, present, derr := m.declaredText() - a.Present = present - if derr != nil { - a.Present, a.Valid = true, derr.Error() - } - p, perr := m.profile() - if perr != nil { - return a, perr - } - target := filepath.Join(p.path, "user.js") - a.Profile = m.tilde(strings.TrimPrefix(target, m.Root)) - accounts, _, aerr := m.readAccounts() - merged := mergedPrefs(p.path) - if aerr != nil { - a.AccountsProblem = aerr.Error() - accounts = nil - } else { - a.Accounts = len(accounts) - a.Each = resolveAccounts(accounts, merged, m.readRecord(p.Dir)) - } - conf, cerr := m.readConfiguration() - if cerr != nil { - a.ConfigurationProblem = cerr.Error() - } - plan := m.planConfiguration(p, conf, accounts, a.Each, merged) - a.Configuration, a.Filters = plan.Entries, plan.filters - a.Waiting = m.waiting(p.path, plan, a.Each) - var want []byte - if a.Present && a.Valid == "" { - if verr := validUserJS(declaredText); verr != nil { - a.Valid = verr.Error() - } - want = []byte(composeUserJS(declaredText, accounts, a.Each) + plan.configBlock()) - a.Content = m.answeredUserJS(string(want)) - for _, line := range strings.Split(string(want), "\n") { - for _, st := range splitStatements(strings.TrimSpace(line)) { - if statement.MatchString(st) { - a.Statements++ - } - } - } - } - have, herr := readBounded(target) - switch { - case herr != nil: - a.Placed = "none: the profile has no user.js" - case a.Present && bytes.Equal(have, want): - a.Placed = "the module's, as declared" - case bytes.HasPrefix(have, []byte(mark)): - a.Placed = "the module's, from an earlier declaration" - default: - a.Placed = "not the module's: a user.js of the operator's, which the module never overwrites" - } - if apply { - switch { - case !a.Present: - return a, fmt.Errorf("the setting prefs is not on this machine (~/%s): the module is not pushed here yet", prefsFile) - case a.Valid != "": - return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid) - case a.AccountsProblem != "": - return a, fmt.Errorf("nothing was placed: %s", a.AccountsProblem) - case a.ConfigurationProblem != "": - return a, fmt.Errorf("nothing was placed: %s", a.ConfigurationProblem) - } - saved, _ := readBounded(filepath.Join(p.path, "prefs.js")) - savedPrefs := readPrefs(saved) - for i, r := range a.Each { - if r.How != refused { - a.Each[i].Changed = changedPrefs(r, accounts[r.name], savedPrefs) - } - } - switch { - case herr == nil && !bytes.HasPrefix(have, []byte(mark)): - return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile) - } - if herr != nil || !bytes.Equal(have, want) { - tmp := target + ".mesh-new" - if err := os.WriteFile(tmp, want, 0o644); err != nil { - return a, err - } - if err := os.Rename(tmp, target); err != nil { - os.Remove(tmp) - return a, err - } - } - a.Applied, a.Placed = true, "the module's, as declared" - if err := m.writeRecord(p.Dir, a.Each); err != nil { - return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err) - } - if lists, err := m.writeAccountLists(p.path, a.Each); err != nil { - a.Lists = "not written: " + err.Error() - } else { - a.Lists = lists - } - if closed, err := m.writeClosed(p, &plan); err != nil { - a.Closed = "not written: " + err.Error() - } else { - a.Closed = closed - } - a.Filters = plan.filters - if err := m.writeConfigRecord(p.Dir, plan.record); err != nil { - return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err) - } - a.Waiting = m.waiting(p.path, plan, a.Each) - } - if ps := m.mainProcs(); len(ps) > 0 { - if st, err := os.Stat(target); err == nil { - if t, err := time.Parse(time.RFC3339, ps[0].Since); err == nil && st.ModTime().After(t) { - a.Effective = "not yet: Betterbird reads user.js when it starts, and it started before the file was placed (betterbird_restart)" - } else if err == nil { - a.Effective = "yes: the running Betterbird started after the file was placed" - } - } - } - return a, nil -} - -// answeredUserJS is the user.js as a tool answers it (novox/hq ADR 0316 §16, ADR 0317 §9): an account's -// preferences with their values withheld, every other value masked as betterbird_prefs masks it. -func (m *Machine) answeredUserJS(text string) string { - lines := strings.Split(text, "\n") - for i, line := range lines { - for _, st := range splitStatements(strings.TrimSpace(line)) { - mm := prefLine.FindStringSubmatch(st) - if mm == nil { - continue - } - value := m.maskValue(mm[2]) - if why := withheld(mm[1]); why != "" { - value = `""` - } - lines[i] = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), value) - } - } - return strings.Join(lines, "\n") -} - -// Prepare is what the module's start runs before Betterbird (`betterbird-tools prepare`): with no Betterbird -// of this account running, the user.js placed and the declared accounts written into the account lists. With -// one running it does nothing, since the start then only hands over to it. Its error is said by the start, -// which starts Betterbird either way. -func (m *Machine) Prepare() error { - if len(m.mainProcs()) > 0 { - return nil - } - u, err := m.userJS(true) - if err != nil { - return err - } - var problems []string - if strings.HasPrefix(u.Lists, "not written") { - problems = append(problems, "the account lists were "+u.Lists) - } - problems = append(problems, u.refusedAccounts()...) - if strings.HasPrefix(u.Closed, "not written") { - problems = append(problems, "what Betterbird rewrites itself was "+u.Closed) - } - // Said by kind, name and key only: the start's words reach the journal, which holds no address (ADR 0329 §1). - for _, e := range u.Configuration { - if e.How == refused { - problems = append(problems, fmt.Sprintf("%s %s is refused: %s", e.Kind, e.Name, e.Why)) - } - } - for _, fp := range u.Filters { - if fp.Refused != "" { - problems = append(problems, fmt.Sprintf("the filters of %s are refused: %s", fp.Account, fp.Refused)) - } - } - if len(problems) > 0 { - return fmt.Errorf("%s", strings.Join(problems, "; ")) - } - return nil -} - -// Status is what betterbird_status answers. -type Status struct { - Running []Proc `json:"running"` - Children int `json:"children"` - // Installation is what is installed and from where, the policy and Owl (novox/hq ADR 0316 §16). - Installation Installation `json:"installation"` - // Installed is the community package's version, while it is still there. - Installed string `json:"community_package,omitempty"` - Foreign bool `json:"from_the_aur,omitempty"` - Profile *Profile `json:"profile,omitempty"` - Problem string `json:"profile_problem,omitempty"` - Output []Stream `json:"output,omitempty"` - UserJS *UserJS `json:"user_js,omitempty"` - // Accounts are counts: the declared, those in the profile's list, the others, those without a sign-in. - Accounts *AccountCount `json:"accounts,omitempty"` - AddOns []AddOn `json:"add_ons,omitempty"` - // Display are the preferences that decide how its windows and notifications look. - Display []Pref `json:"display_prefs,omitempty"` -} - -// AddOn is one add-on the operator installed: its id, and whether it is on. -type AddOn struct { - ID string `json:"id"` - Version string `json:"version,omitempty"` - Active bool `json:"active"` -} - -// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the -// renderer, and whether notifications go to the desktop's notifier. -var displayPrefs = []string{"calendar.alarms.playsound", "calendar.alarms.show", "calendar.alarms.showmissed", - "calendar.alarms.indicator.show", "alerts.useSystemBackend", "mail.biff.use_system_alert", - "extensions.activeThemeID", "browser.display.use_system_colors", "ui.systemUsesDarkTheme", - "gfx.webrender.software", "gfx.webrender.all", "layers.acceleration.disabled", "widget.gtk.overlay-scrollbars.enabled", - "mozilla.widget.use-argb-visuals"} - -func (m *Machine) foreign(pkg string) bool { - o := m.cmd(0, nil, "pacman", "-Qmq", pkg) - return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg -} - -// Status answers whether Betterbird runs, what is installed, its profile and the module's user.js. -func (m *Machine) Status() (Status, error) { - s := Status{Running: m.mainProcs()} - if s.Running == nil { - s.Running = []Proc{} - } - s.Children = len(m.procs(mainComm)) - len(s.Running) - if v, err := m.installed(packageFor); err == nil && v != "" { - s.Installed, s.Foreign = v, m.foreign(packageFor) - } - if len(s.Running) > 0 { - s.Output = m.streams(s.Running[0].PID) - } - p, err := m.profile() - if err != nil { - s.Installation = m.installation("") - s.Problem = err.Error() - return s, nil - } - s.Profile = &p - s.Installation = m.installation(p.path) - if u, err := m.userJS(false); err == nil { - u.Content = "" - s.UserJS = &u - } - c := m.countAccounts(p.path, p.Dir) - s.Accounts = &c - s.AddOns = addOns(p.path) - if got, err := m.Prefs(displayPrefs); err == nil { - s.Display = got["prefs"].([]Pref) - } - return s, nil -} - -// emailAddress and credentialWord are masked in what Betterbird printed. -var ( - emailAddress = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) - 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, "") - s = emailAddress.ReplaceAllString(s, "") - s = bearerOrBasic.ReplaceAllString(s, "$1 ") - return credentialWord.ReplaceAllString(s, "${1}") -} - -// 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, "") -} - -// mostAnswer bounds a log answer. -const mostAnswer = 128 << 10 - -// Log answers the last n lines Betterbird printed, from the journal of the unit the module starts it -// as; problems keeps the lines that say error, warning, crash or assertion. Addresses and credentials -// are masked. -func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error) { - if unit == "" { - unit = restartAs - } - if unit != restartAs && unit != testUnit { - return nil, fmt.Errorf("unit is %s or %s", restartAs, testUnit) - } - o := m.cmd(0, m.bus(), "journalctl", "--user", "--unit", unit+".service", "--no-pager", "--output", "short-iso", "-n", strconv.Itoa(n)) - if err := failed(o, "journalctl"); err != nil { - return nil, err - } - var lines []string - size := 0 - keep := regexp.MustCompile(`(?i)(error|warn|crash|assert|fail|exception)`) - for _, l := range strings.Split(strings.TrimRight(o.Stdout, "\n"), "\n") { - if l == "" || (problems && !keep.MatchString(l)) { - continue - } - l = mask(l) - if size+len(l) > mostAnswer { - break - } - size += len(l) + 1 - lines = append(lines, l) - } - if lines == nil { - lines = []string{} - } - a := map[string]any{"unit": unit + ".service", "lines": lines} - if ps := m.mainProcs(); len(ps) > 0 && !strings.HasPrefix(ps[0].StartedIn, restartAs) && unit == restartAs { - a["note"] = fmt.Sprintf("the running Betterbird (pid %d) was not started by the module: it runs in %s, "+ - "and its output goes where status says (output), not to this journal; betterbird_restart starts it as %s", - ps[0].PID, ps[0].StartedIn, restartAs) - } - return a, nil -} - -// RestartAnswer is what betterbird_restart answers. -type RestartAnswer struct { - // 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"` -} - -// composerClass is the X instance of a message being written, whatever the class beside it. -var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`) - -// composers counts the open message windows, from the X window tree. -func (m *Machine) composers(s Session) (int, error) { - o := m.cmd(3*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. 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"} - 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(8*time.Second, m.mainProcs()) - if u, err := m.userJS(true); err != nil { - a.UserJS = "not placed: " + err.Error() - } else { - a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s", - u.Statements, prefsFile, u.Accounts, u.Lists) - } - // As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree, - // 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s. - _ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service") - call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs} - for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { - if kv[1] != "" { - call = append(call, "--setenv="+kv[0]+"="+kv[1]) - } - } - call = append(call, "--", startBin) - if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil { - return a, err - } - 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 { - 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 -} - -// CheckAnswer is what betterbird_check answers. -type CheckAnswer struct { - OK bool `json:"ok"` - Findings []Finding `json:"findings"` -} - -// Check verifies what the module promises: Betterbird installed, one running in the session, its -// profile found, the module's user.js declared, valid, placed and in effect, its output read, and no ask at its -// start to be the default mail client (novox/hq issue 345). -func (m *Machine) Check() (CheckAnswer, error) { - a := CheckAnswer{Findings: []Finding{}} - add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) } - if _, err := m.installed(packageFor); err != nil { - return a, err - } - p, perr := m.profile() - profilePath := "" - if perr == nil { - profilePath = p.path - } - in := m.installation(profilePath) - switch { - case in.From == "not installed": - add("Betterbird is not installed in "+installDir, "the module's push places the mesh's release; it waits for the operator's push (upgrade policy record)") - case in.From != "the mesh": - add("Betterbird is "+in.From, "quit Betterbird, then push the module to this node: the push removes the community package and places the mesh's release") - } - if in.Behind { - add("Betterbird "+in.Version+" (build "+in.Build+") is behind the newest published build "+in.Newest, - "move the module's pin in a reviewed change (novox/hq ADR 0316 §5)") - } - if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") { - add("the enterprise policy is "+in.Policy, "push the module to this node") - } - if in.From == "the mesh" && !exists(m.path(meshDefaults)) { - add("the module's defaults are not placed ("+meshDefaults+")", "push the module to this node") - } - if c, _, err := m.config(); err == nil { - var cleared []string - for _, cp := range c.Prefs { - switch { - case cp.Drift == "": - case cp.Remedy != "": - add(cp.Name+" drifts from the module's: "+cp.Drift, cp.Remedy) - default: - cleared = append(cleared, cp.Name) - } - } - if len(cleared) > 0 { - add(fmt.Sprintf("%d managed preference(s) drift from the module's defaults: the profile saved its own value and no "+ - "node layer says so (%s)", len(cleared), strings.Join(cleared, ", ")), - "betterbird_config shows each; to keep a value on this node, betterbird_config with keep proposes a node "+ - "layer; otherwise quit Betterbird and call betterbird_config_apply with apply") - } - if len(c.Refused) > 0 { - add(fmt.Sprintf("the module's defaults list %d preference(s) the tools never manage (%s)", len(c.Refused), strings.Join(c.Refused, "; ")), - "take them out of install/defaults-pref/mesh-defaults.js in a reviewed catalogue change: an account is declared in the setting accounts") - } - } - if why := m.defaultClientAsk(profilePath); why != "" && in.From != "not installed" { - add("Betterbird asks at its start to be the default mail client: "+why, - "push the module to this node: its policy locks "+defaultClientPref+" off (novox/hq issue 345)") - } - switch { - case in.From != "the mesh": - case in.Owl == nil || in.Owl.Pinned == "": - add("Owl's xpi is not placed ("+owlFile+")", "push the module to this node") - case in.Owl.InProfile == "": - add("Owl is not installed in the profile yet", "Betterbird installs it from the policy at its next start") - case !in.Owl.AtThePin && !newerVersion(in.Owl.InProfile, in.Owl.Pinned): - add("Owl "+in.Owl.InProfile+" runs in the profile, older than the mesh's pin "+in.Owl.Pinned, - "Owl is installed by policy and cannot be removed in Betterbird: move the module's Owl pin in a reviewed change (novox/hq ADR 0316 §6)") - case !in.Owl.Active: - add("Owl is installed and not active", "turn it on in Betterbird's add-ons") - } - if perr == nil { - c := m.countAccounts(p.path, p.Dir) - for _, r := range c.Each { - if r.How == refused { - add("refused: "+r.Why, "as it says; the other declared accounts go ahead") - } - } - switch { - case c.InList < c.Declared: - add(fmt.Sprintf("%d of %d declared account(s) are not in the profile's account list yet", c.Declared-c.InList, c.Declared), - "quit Betterbird and start it from the launcher, or betterbird_restart") - } - if c.WithoutSignIn > 0 { - add(fmt.Sprintf("%d declared account(s) have not signed in yet", c.WithoutSignIn), - "a person signs in once, in Betterbird's own window, when it asks (novox/hq ADR 0316 §14)") - } - // The five kinds of ADR 0329: drift and what waits for Betterbird to close, per kind (§9, §11, §12). - if _, err := m.readConfiguration(); err != nil { - add(err.Error(), "change the setting at the controller's terminal, or derive and propose it (betterbird_derive)") - } else if v, err := m.view(); err == nil { - drift := m.driftCounts(v) - waiting := m.waiting(v.p.path, v.plan, v.ra) - for _, k := range allKinds { - if n := drift[k]; n > 0 { - add(fmt.Sprintf("%d declared %s differ from the profile, are not in it yet or are refused", n, k), - "betterbird_drift with kind "+k+" says which and how; the module's user.js holds them from Betterbird's next start (betterbird_restart)") - } - if n := waiting[k]; n > 0 { - add(fmt.Sprintf("%d change(s) to %s are not written yet: they are written only while Betterbird is closed", n, k), - "quit Betterbird and start it from the launcher, or betterbird_restart (novox/hq ADR 0329 §9)") - } - } - } - } - ps := m.mainProcs() - switch { - case len(ps) == 0: - add("Betterbird is not running", "start it from the launcher, or betterbird_restart") - case len(ps) > 1: - add(fmt.Sprintf("%d Betterbird main processes run", len(ps)), "one profile is held by one process: betterbird_restart") - } - u, err := m.userJS(false) - switch { - case err != nil: - add("no profile: "+err.Error(), "start Betterbird once") - case !u.Present: - add("the setting prefs is not on this machine (~/"+prefsFile+")", "the module is not pushed here: the controller's push") - case u.Valid != "": - add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs") - case u.AccountsProblem != "": - add("the module's user.js is not placed: "+u.AccountsProblem, "change the module's setting accounts at the controller's terminal, or propose it (betterbird_accounts)") - case !strings.HasPrefix(u.Placed, "the module's, as declared"): - add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart") - case strings.HasPrefix(u.Effective, "not yet"): - add("the module's user.js is placed but not in effect: "+u.Effective, "betterbird_restart") - } - if len(ps) > 0 { - for _, s := range m.streams(ps[0].PID) { - if s.Dead { - add(fmt.Sprintf("Betterbird's fd %d is a pipe nobody reads (a session started before its output went to the journal): what it prints is lost", s.FD), - "betterbird_restart, which starts it with its output in the journal") - } - } - } - a.OK = len(a.Findings) == 0 - return a, nil -} diff --git a/modules/betterbird/cmd/betterbird-tools/betterbird_test.go b/modules/betterbird/cmd/betterbird-tools/betterbird_test.go deleted file mode 100644 index 4296f76d..00000000 --- a/modules/betterbird/cmd/betterbird-tools/betterbird_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -const profileDir = testHome + "/.thunderbird/60016jsp.default-default" - -// links stands in for the kernel's fd links: path → target. -func links(t *testing.T, m map[string]string) { - t.Helper() - old := readLink - readLink = func(p string) (string, error) { - for k, v := range m { - if strings.HasSuffix(p, k) { - return v, nil - } - } - return "", os.ErrNotExist - } - t.Cleanup(func() { readLink = old }) -} - -// betterbird is a running Betterbird with one child, its profile and its prefs. -func (f *fake) betterbird() { - f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1") - f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope") - f.write("/proc/4000/fd/40", "") - f.write("/proc/4000/fd/41", "") - f.write("/proc/4000/fd/1", "") - f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+ - "[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n") - f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ - `user_pref("browser.display.use_system_colors", true);`+"\n"+ - `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+ - `user_pref("calendar.alarms.showmissed", true);`+"\n"+ - `user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+ - `user_pref("extensions.owl.token", "abc");`+"\n"+ - `user_pref("signon.rememberSignons", true);`+"\n") - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`) -} - -// declare writes the setting prefs as the node-engine does (prefs.json, novox/hq issue 586), from user_pref -// statements: each one entry of the map. -func (f *fake) declare(prefs string) { - f.t.Helper() - values := map[string]any{} - for name, lit := range statementsOf([]byte(prefs)) { - v, ok := prefValue(lit) - if !ok { - f.t.Fatalf("%s: %s is not a value the setting holds", name, lit) - } - values[name] = v - } - body, _ := json.Marshal(map[string]any{"prefs": values}) - f.write(testHome+"/"+prefsFile, string(body)) -} - -func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"}) - p, err := f.profile() - if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") { - t.Fatalf("%+v %v", p, err) - } - links(t, map[string]string{}) - p, err = f.profile() - if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") { - t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err) - } -} - -func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, nil) - f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n") - got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"}) - if err != nil { - t.Fatal(err) - } - ps := got["prefs"].([]Pref) - by := map[string]Pref{} - for _, p := range ps { - by[p.Name] = p - } - if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") { - t.Fatalf("user.js wins: %+v", p) - } - for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} { - if p := by[n]; p.Value != "" || p.Withheld == "" { - t.Errorf("%s: %+v", n, p) - } - } - if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") { - t.Fatalf("%+v", p) - } - if _, err := f.Prefs(nil); err == nil { - t.Fatal("names are required") - } -} - -func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, nil) - if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") { - t.Fatalf("nothing declared: %v", err) - } - f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`) - u, err := f.userJS(true) - if err != nil || !u.Applied || u.Statements != 2 { - t.Fatalf("%+v %v", u, err) - } - placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")) - declared, _, _ := f.declaredText() - if string(placed) != declared || !strings.Contains(declared, `user_pref("ui.key.x", "a;b");`) { - t.Fatalf("placed whole, as declared:\n%s", placed) - } - if !strings.HasPrefix(u.Effective, "yes") { - t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective) - } - later := time.Unix(1_900_000_000, 0) - os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later) - if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") { - t.Fatalf("placed after Betterbird started: %q", u.Effective) - } - f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n") - if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") { - t.Fatalf("an operator's user.js is never overwritten: %v", err) - } - if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" { - t.Fatal("and it is left as it was") - } -} - -func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) { - for text, ok := range map[string]bool{ - mark + ")\n// none\n": true, - mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true, - mark + ")\n" + `pref("a.b", true);`: false, - mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false, - mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false, - `user_pref("a.b", true);`: false, - } { - if err := validUserJS(text); (err == nil) != ok { - t.Errorf("%q: %v", text, err) - } - } -} - -func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"}) - f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n") - f.answer = func(name string, args []string) Output { - if name == "pacman" && args[0] == "-Q" { - return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} - } - if name == "pacman" && args[0] == "-Qmq" { - return Output{Stdout: "betterbird-bin\n"} - } - return Output{} - } - s, err := f.Status() - if err != nil { - t.Fatal(err) - } - if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign || - s.Installation.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" { - t.Fatalf("%+v", s) - } - if !s.Output[0].Dead { - t.Fatalf("the session's dead pipe: %+v", s.Output) - } - found := false - for _, p := range s.Display { - if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` { - found = true - } - } - if !found { - t.Fatalf("%+v", s.Display) - } -} - -func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"}) - f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} } - a, err := f.Check() - if err != nil || a.OK { - t.Fatalf("%+v %v", a, err) - } - all := "" - for _, x := range a.Findings { - all += x.What + " | " + x.Do + "\n" - } - if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") { - t.Fatal(all) - } -} - -func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.declare("// none") - f.onStart = func(argv []string) { - f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service") - } - a, err := f.Restart(false) - if err != nil { - t.Fatal(err) - } - if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") { - t.Fatalf("%+v", a) - } - if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") { - t.Fatalf("%v", f.calls) - } -} - -func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.declare(`user_pref("calendar.alarms.show", true);`) - before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js")) - a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false) - if err != nil { - t.Fatal(err) - } - if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 { - t.Fatalf("%+v", a) - } - dir := filepath.Join(f.Root, "/run/user/1000", testUnit) - user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js")) - cal, _ := os.ReadFile(filepath.Join(dir, "test.ics")) - if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) || - !strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") { - t.Fatalf("%s", user) - } - // Betterbird's update check stays off, so no About window opens on the operator's screen, and the - // test's own preferences come last, so nothing carried or declared overrides them. - if !strings.Contains(string(user), `"betterbird.update.checkIntervalDays", 0`) || - strings.Index(string(user), "extensions.activeThemeID") > strings.Index(string(user), "calendar.registry.meshtest.uri") || - strings.Index(string(user), `"calendar.alarms.show"`) > strings.Index(string(user), "calendar.registry.meshtest.uri") { - t.Fatalf("%s", user) - } - // The event starts 10 minutes after the reminder, which is due 40 s from now. - if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") || - a.Due != "2027-01-15T08:00:40Z" { - t.Fatalf("%s %s", cal, a.Due) - } - if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") { - t.Fatalf("%v", f.calls) - } - after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js")) - if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) { - t.Fatal("the operator's profile is untouched") - } - if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) { - t.Fatalf("stop removes the test profile: %v", err) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/config.go b/modules/betterbird/cmd/betterbird-tools/config.go deleted file mode 100644 index 25635e10..00000000 --- a/modules/betterbird/cmd/betterbird-tools/config.go +++ /dev/null @@ -1,666 +0,0 @@ -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() -} diff --git a/modules/betterbird/cmd/betterbird-tools/config_test.go b/modules/betterbird/cmd/betterbird-tools/config_test.go deleted file mode 100644 index c92a7288..00000000 --- a/modules/betterbird/cmd/betterbird-tools/config_test.go +++ /dev/null @@ -1,541 +0,0 @@ -package main - -import ( - "archive/zip" - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// The configuration the module ships (novox/hq issue 345): the policy's locks, the module's defaults file, and the -// tools that show the layers, flag drift, clear it and propose a node layer. - -// shippedDefaultsWant is the setup the operator approved on 2026-10-12 (the laptop's, with reminders missed while -// asleep shown at the next start and chat off), which the module shipped as its defaults file until issue 586 moved -// it into the mesh-wide layer of the setting prefs: the catalogue holds none of it now. -var shippedDefaultsWant = map[string]string{ - "mail.uidensity": "0", - "mail.uifontsize": "14", - "mail.dark-reader.enabled": "false", - "mail.pane_config.dynamic": "2", - "mail.pane_config.multiline_all": "true", - "mail.threadpane.listview": "0", - "mailnews.default_view_flags": "1", - "mail.warn_on_shift_delete": "false", - "mail.biff.show_alert": "false", - "mail.biff.play_sound": "false", - "mail.biff.show_tray_icon": "false", - "mail.biff.use_system_alert": "true", - "calendar.view.daystarthour": "9", - "calendar.view.dayendhour": "20", - "calendar.item.promptDelete": "false", - "calendar.timezone.useSystemTimezone": "true", - "calendar.alarms.show": "true", - "calendar.alarms.showmissed": "true", - "mail.chat.enabled": "false", - "mailnews.message_display.disable_remote_image": "true", - "privacy.donottrackheader.enabled": "true", -} - -var shippedKeepsWant = []string{"extensions.activeThemeID", "browser.display.use_system_colors", - "layout.css.always_underline_links", "mail.spam.manualMark", "mail.compose.show_attachment_pane"} - -func repoFile(t *testing.T, rel string) []byte { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", rel)) - if err != nil { - t.Fatal(err) - } - return raw -} - -// withDefaults places the module's defaults file as the push does. -func (f *fake) withDefaults() { - f.t.Helper() - f.write(meshDefaults, string(repoFile(f.t, "install/defaults-pref/mesh-defaults.js"))) -} - -// omni places a packed application holding the given files. -func (f *fake) omni(files map[string]string) { - f.t.Helper() - full := filepath.Join(f.Root, omniJa) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - f.t.Fatal(err) - } - out, err := os.Create(full) - if err != nil { - f.t.Fatal(err) - } - w := zip.NewWriter(out) - for name, body := range files { - e, _ := w.Create(name) - e.Write([]byte(body)) - } - w.Close() - out.Close() -} - -// betterbirdsOwn is a small omni.ja: two files that contradict each other, as all-thunderbird.js and -// mailnews.js do in the pinned release. -func (f *fake) betterbirdsOwn() { - f.omni(map[string]string{ - "greprefs.js": `pref("gfx.webrender.software", false);` + "\n", - "defaults/pref/all-thunderbird.js": `pref("offline.autoDetect", true);` + "\n" + - `pref("mail.threadpane.listview", 1);` + "\n" + `pref("mail.uidensity", 1);` + "\n" + - `pref("mail.shell.checkDefaultClient", true);` + "\n", - "defaults/pref/mailnews.js": `pref("offline.autoDetect", false);` + "\n" + - `pref("mail.biff.show_alert", true); // a comment after it` + "\n" + `pref("mail.spam.manualMark", false);` + "\n" + - `pref("mailnews.start_page.enabled", true);` + "\n", - "defaults/pref/calendar.js": `pref("calendar.alarms.showmissed", false);` + "\n", - "chrome/other.js": `pref("not.a.default", 1);` + "\n", - }) -} - -func stopBetterbird(t *testing.T, f *fake) { - t.Helper() - for _, pid := range []string{"4000", "4001"} { - if err := os.RemoveAll(filepath.Join(f.Root, "proc", pid)); err != nil { - t.Fatal(err) - } - } -} - -func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) { - m, _ := readManifest(t) - if a := artifact(t, m, "defaults"); a["from"] != "install/defaults-pref" { - t.Fatalf("the defaults file ships through the defaults artifact: %v", a) - } - if r := resource(t, m, "spelling"); r["path"] != prefDir || r["artifact"] != "defaults" { - t.Fatalf("placed in Betterbird's own defaults directory: %v", r) - } - raw := repoFile(t, "install/distribution/policies.json") - if why := policyProblem(raw); why != "" { - t.Fatal(why) - } - var p struct { - Policies map[string]json.RawMessage `json:"policies"` - } - if err := json.Unmarshal(raw, &p); err != nil { - t.Fatal(err) - } - for k, want := range map[string]string{"DisableAppUpdate": "true", "DisableTelemetry": "true", "ExtensionUpdate": "false"} { - if string(p.Policies[k]) != want { - t.Errorf("%s is %s, not %s", k, p.Policies[k], want) - } - } - prefs := policyPrefsOf(raw) - for _, n := range []string{"mail.shell.checkDefaultClient", "mail.inappnotifications.enabled", "mailnews.start_page.enabled"} { - if prefs[n] != (policyPref{Value: "false", Locked: true}) { - t.Errorf("%s is locked off: %+v", n, prefs[n]) - } - } - // Betterbird's Preferences policy accepts only these prefixes (Policies.sys.mjs); privacy. and toolkit. are not. - allowed := []string{"accessibility.", "app.update.", "browser.", "calendar.", "chat.", "datareporting.policy.", "dom.", - "extensions.", "general.autoScroll", "general.smoothScroll", "geo.", "gfx.", "intl.", "layers.", "layout.", "mail.", - "mailnews.", "media.", "network.", "pdfjs.", "places.", "print.", "signon.", "spellchecker.", "ui.", "widget."} - for n := range prefs { - ok := false - for _, a := range allowed { - ok = ok || strings.HasPrefix(n, a) - } - if !ok { - t.Errorf("the policy cannot lock %s: its prefix is not one Betterbird accepts", n) - } - } - - text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js")) - sh := parseShipped([]byte(text)) - if len(sh.values) != 0 { - t.Errorf("the module's defaults hold no value of the operator's: they are the setting prefs (issue 586): %v", sh.values) - } - for n := range shippedDefaultsWant { - if strings.Contains(text, n) { - t.Errorf("%s is the operator's choice and lives in the setting prefs, never in the catalogue", n) - } - } - var keeps []string - for _, n := range sh.order { - if sh.keeps[n] { - keeps = append(keeps, n) - } - } - if !reflect.DeepEqual(keeps, shippedKeepsWant) { - t.Errorf("the preferences kept at Betterbird's default: %v", keeps) - } - lines := strings.Split(text, "\n") - for i, l := range lines { - if defaultLine.MatchString(l) && (i == 0 || !strings.HasPrefix(lines[i-1], "// ")) { - t.Errorf("line %d has no comment saying why: %s", i+1, l) - } - if t2 := strings.TrimSpace(l); t2 != "" && !strings.HasPrefix(t2, "//") && !defaultLine.MatchString(l) { - t.Errorf("line %d is neither a comment nor a pref(…) line: %s", i+1, l) - } - } - for n := range sh.values { - if withheld(n) != "" || passwordSecret.MatchString(n) { - t.Errorf("%s is an account's or a credential's preference, never a default of the module", n) - } - if _, locked := prefs[n]; locked { - t.Errorf("%s is both locked and a default", n) - } - } -} - -// T2: Betterbird's own defaults are values read from omni.ja, in Gecko's order, and the module's beside them. -func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - f.withDefaults() - f.betterbirdsOwn() - links(t, nil) - got, err := f.Prefs([]string{"offline.autoDetect", "mail.threadpane.listview", "mail.uidensity", "gfx.webrender.software", - "mailnews.start_page.enabled", "layout.css.always_underline_links", "not.a.default"}) - if err != nil { - t.Fatal(err) - } - by := map[string]Pref{} - for _, p := range got["prefs"].([]Pref) { - by[p.Name] = p - } - if p := by["offline.autoDetect"]; p.Value != "true" || p.Default != "true" || !strings.Contains(p.DefaultFrom, "all-thunderbird.js") { - t.Errorf("of two files, the one first by name holds, as Gecko reads them: %+v", p) - } - if p := by["mail.threadpane.listview"]; p.Value != "1" || strings.Contains(p.From, "module") || strings.Contains(p.DefaultFrom, "module") { - t.Errorf("the module ships no value of the operator's, so Betterbird's own default holds: %+v", p) - } - if p := by["gfx.webrender.software"]; p.Value != "false" || !strings.Contains(p.From, "greprefs.js") { - t.Errorf("%+v", p) - } - if p := by["mailnews.start_page.enabled"]; p.Value != "false" || p.From != "locked by the module's policy" || p.Default != "true" { - t.Errorf("a locked preference says its lock and Betterbird's default under it: %+v", p) - } - if p := by["layout.css.always_underline_links"]; p.Value != "" || !strings.Contains(p.From, "built-in") { - t.Errorf("a preference in no file is said as such: %+v", p) - } - if p := by["not.a.default"]; p.Default != "" { - t.Errorf("only defaults/pref and greprefs.js are defaults: %+v", p) - } -} - -// T4: of mailnews.* only what names an account, a server or the operator's tags is withheld. -func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) { - for name, held := range map[string]bool{ - "mailnews.start_page.enabled": false, - "mailnews.default_view_flags": false, - "mailnews.message_display.disable_remote_image": false, - "mailnews.tags.$label1.tag": true, - "mailnews.smtp.loglevel": true, - "mailnews.reply_to_self_check_all_ident": true, - "mailnews.auto_config.fetchFromISP.sendEmailAddress": true, - "mailnews.oauth.useSchemeRedirect": true, - "mail.server.server1.hostname": true, - } { - if got := withheld(name) != ""; got != held { - t.Errorf("%s: withheld %v, want %v", name, got, held) - } - } -} - -// T1: every managed preference through the layers, and drift: a declared value the profile's user.js does not hold -// yet, or a value the profile saved where the module keeps Betterbird's default. -func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - f.withDefaults() - f.betterbirdsOwn() - links(t, nil) - f.declare(`user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", false); user_pref("calendar.alarms.showmissed", true);`) - f.write(profileDir+"/user.js", mark+")\n"+`user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n") - f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ - `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+ - `user_pref("mail.uifontsize", 12);`+"\n"+ - `user_pref("mail.biff.show_alert", true);`+"\n"+ - `user_pref("mail.spam.manualMark", true);`+"\n"+ - `user_pref("browser.display.use_system_colors", false);`+"\n"+ - `user_pref("mailnews.start_page.enabled", true);`+"\n") - c, err := f.Config(nil) - if err != nil { - t.Fatal(err) - } - by := map[string]ConfigPref{} - for _, p := range c.Prefs { - by[p.Name] = p - } - if len(c.Prefs) != 3+3+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") { - t.Fatalf("every lock, declared preference and kept default: %d, %s", len(c.Prefs), c.Defaults) - } - if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.spam.manualMark", "mail.biff.show_alert"}) { - t.Fatalf("drift: %v", c.Drifting) - } - if p := by["mail.biff.show_alert"]; p.Layer != "declared" || p.Declared != "false" || p.Profile != "true" || - p.Effective != "true" || !strings.Contains(p.Drift, "the setting prefs says false") || !strings.Contains(p.Remedy, "betterbird_user_js") { - t.Errorf("a declared value the profile's user.js does not hold yet: %+v", p) - } - if p := by["mail.spam.manualMark"]; p.Layer != "Betterbird's default" || p.BetterbirdDefault != "false" || - !strings.Contains(p.Drift, "beats the module's false") || p.Remedy != "" { - t.Errorf("a saved value where the module keeps Betterbird's default: %+v", p) - } - if p := by["extensions.activeThemeID"]; p.Layer != "Betterbird's default" || !strings.Contains(p.Drift, `"default-theme@mozilla.org"`) || - !strings.Contains(p.Remedy, "add-on manager") || p.Profile != `"thunderbird-compact-dark@mozilla.org"` { - t.Errorf("another theme is drift whose remedy is the add-on manager, its id answered whole: %+v", p) - } - if p := by["browser.display.use_system_colors"]; p.Drift != "" { - t.Errorf("a saved value equal to Betterbird's default is not drift: %+v", p) - } - if p := by["mail.uifontsize"]; p.Drift != "" || p.Declared != "16" || p.Effective != "16" || !strings.Contains(p.From, "user.js") { - t.Errorf("a declared value the user.js holds wins at the start, whatever prefs.js saved: %+v", p) - } - if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" { - t.Errorf("a lock wins over the profile: %+v", p) - } - if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.Layer != "declared" { - t.Errorf("%+v", p) - } - // T5: the check says the drift, with both remedies. - ch, err := f.Check() - if err != nil { - t.Fatal(err) - } - found := false - for _, x := range ch.Findings { - found = found || (strings.Contains(x.What, "1 managed preference(s) drift") && strings.Contains(x.What, "mail.spam.manualMark") && - strings.Contains(x.Do, "betterbird_config_apply") && strings.Contains(x.Do, "keep")) - } - theme := false - for _, x := range ch.Findings { - theme = theme || (strings.HasPrefix(x.What, "extensions.activeThemeID drifts") && strings.Contains(x.Do, "add-on manager") && - !strings.Contains(x.Do, "betterbird_config_apply")) - } - declared := false - for _, x := range ch.Findings { - declared = declared || (strings.HasPrefix(x.What, "mail.biff.show_alert drifts") && strings.Contains(x.Do, "betterbird_user_js")) - } - if !found || !theme || !declared { - t.Fatalf("the check reports the drift: %+v", ch.Findings) - } - os.Remove(filepath.Join(f.Root, meshDefaults)) - ch, _ = f.Check() - found = false - for _, x := range ch.Findings { - found = found || strings.Contains(x.What, "the module's defaults are not placed") - } - if !found { - t.Fatalf("defaults missing on a node of the mesh's release is a finding: %+v", ch.Findings) - } -} - -// T3: the drifting values are cleared from prefs.js only while Betterbird is not running, and nothing else. -func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - f.withDefaults() - f.betterbirdsOwn() - links(t, nil) - f.declare("// none") - prefs := "// Mozilla User Preferences\n" + - `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" + - `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + - `user_pref("mail.spam.manualMark", true);` + "\n" + - `user_pref("mail.uidensity", 0);` + "\n" - f.write(profileDir+"/prefs.js", prefs) - dry, err := f.ConfigApply(false) - if err != nil || dry.Applied || len(dry.Clears) != 1 || len(dry.NotCleared) != 1 { - t.Fatalf("a dry run lists and changes nothing: %+v %v", dry, err) - } - if f.read(profileDir+"/prefs.js") != prefs { - t.Fatal("a dry run wrote prefs.js") - } - if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "running") { - t.Fatalf("refused while Betterbird runs: %v", err) - } - if f.read(profileDir+"/prefs.js") != prefs { - t.Fatal("prefs.js changed while Betterbird runs") - } - stopBetterbird(t, f) - f.write(profileDir+"/lock", "") - if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "locked") { - t.Fatalf("refused while the profile's own lock is there: %v", err) - } - if err := os.Remove(filepath.Join(f.Root, profileDir, "lock")); err != nil { - t.Fatal(err) - } - a, err := f.ConfigApply(true) - if err != nil || !a.Applied { - t.Fatalf("%+v %v", a, err) - } - want := "// Mozilla User Preferences\n" + - `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" + - `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + - `user_pref("mail.uidensity", 0);` + "\n" - if got := f.read(profileDir + "/prefs.js"); got != want { - t.Fatalf("only the drifting lines go:\n%s", got) - } - said := map[string]Cleared{} - for _, c := range a.Clears { - said[c.Name] = c - } - if c := said["mail.spam.manualMark"]; c.Was != "true" || c.Now != "false" { - t.Errorf("it says what it cleared and what holds now: %+v", c) - } - if _, cleared := said["extensions.activeThemeID"]; cleared || len(a.NotCleared) != 1 || !strings.Contains(a.NotCleared[0], "add-on manager") { - t.Errorf("the theme is never cleared, and said with its remedy: %+v", a) - } - raw, _ := json.Marshal(a) - if strings.Contains(string(raw), "example.org") { - t.Fatalf("no account value leaves: %s", raw) - } - if c, _ := f.Config(nil); !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) { - t.Fatalf("once applied, only the theme drifts, until the add-on manager changes it: %v", c.Drifting) - } - os.Remove(filepath.Join(f.Root, meshDefaults)) - if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "not placed") { - t.Fatalf("without the module's defaults there is nothing to come back to: %v", err) - } -} - -// T6: keeping a value on one node is that node's layer of the setting prefs proposed through the controller, one -// entry per preference beside what the layer holds (issue 586), never a write to user.js. -func TestKeepProposesANodeLayerThroughTheController(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - f.withDefaults() - f.betterbirdsOwn() - links(t, nil) - f.declare(`user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 12);`) - f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n") - oldNode, oldAsk := nodeName, asker - t.Cleanup(func() { nodeName, asker = oldNode, oldAsk }) - nodeName = func() string { return "shanks" } - var asked []map[string]any - asker = func(v string, a any) (json.RawMessage, error) { - args := a.(map[string]any) - asked = append(asked, args) - if args["propose"] == nil { - return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on shanks, every value"}`), nil - } - return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil - } - before := f.read(testHome + "/" + prefsFile) - c, err := f.Config([]string{"mail.uifontsize", "mail.spam.manualMark"}) - if err != nil { - t.Fatal(err) - } - if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" || - asked[1]["module"] != "betterbird" { - t.Fatalf("the node's layer read, then proposed: %v", asked) - } - var values map[string]any - if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &values); err != nil { - t.Fatal(err) - } - want := map[string]any{"calendar.alarms.playsound": false, "mail.uifontsize": float64(16), "mail.spam.manualMark": true} - if !reflect.DeepEqual(values["prefs"], want) || values["identities"] == nil { - t.Fatalf("the layer as it stands with the kept values, one entry each:\n got %v\nwant %v", values, want) - } - if c.Proposed != "proposed: p-1" || f.read(testHome+"/"+prefsFile) != before { - t.Fatalf("proposed, and nothing written: %+v", c) - } - for _, bad := range [][]string{{"mailnews.start_page.enabled"}, {"some.other.pref"}, {"calendar.alarms.playsound"}} { - asked = nil - if _, err := f.Config(bad); err == nil || len(asked) != 0 { - t.Errorf("%v: a lock, an unmanaged name or a value the profile did not save is refused: %v", bad, err) - } - } - nodeName = func() string { return "" } - if _, err := f.Config([]string{"mail.uifontsize"}); err == nil || !strings.Contains(err.Error(), "MESH_NODE") { - t.Fatalf("%v", err) - } -} - -// The review of pull request 253: whether Betterbird asks to be the default client is decided from the policy's -// own Preferences entry, never from what else the policy lacks. -func TestTheDefaultClientAskIsDecidedFromThePreferencesEntry(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - links(t, nil) - f.write(policyFile, `{"policies": {"ExtensionSettings": {}}}`) - if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why == "" { - t.Fatal("a policy lacking the updater and the lock still asks") - } - f.write(policyFile, `{"policies": {"Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "locked"}}}}`) - if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why != "" { - t.Fatalf("the lock holds whatever else the policy lacks: %s", why) - } -} - -// The review of pull request 256: the theme is the add-on database's, and a saved default is not drift. -func TestTheThemeIsTheAddOnDatabasesAndASavedDefaultIsNotDrift(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - f.withDefaults() - links(t, nil) - f.write(profileDir+"/prefs.js", `user_pref("browser.display.use_system_colors", false);`+"\n"+ - `user_pref("layout.css.always_underline_links", false);`+"\n") - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"thunderbird-compact-dark@mozilla.org","type":"theme","active":true,"location":"app-builtin"},`+ - `{"id":"default-theme@mozilla.org","type":"theme","active":false,"location":"app-builtin"}]}`) - c, err := f.Config(nil) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) { - t.Fatalf("the active theme from the add-on database, with no preference saved, is drift; a saved default is not: %v", c.Drifting) - } - f.write(profileDir+"/prefs.js", `user_pref("extensions.activeThemeID", "default-theme@mozilla.org");`+"\n") - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"default-theme@mozilla.org","type":"theme","active":true,"location":"app-builtin"}]}`) - if c, _ := f.Config(nil); len(c.Drifting) != 0 { - t.Fatalf("a profile back on the default theme does not drift: %v", c.Drifting) - } - if _, err := f.Config([]string{"extensions.activeThemeID"}); err == nil || !strings.Contains(err.Error(), "add-on manager") { - t.Fatalf("a node layer cannot choose the theme: %v", err) - } -} - -// The review of pull request 256: a credential's or an account's name in the defaults file is never managed. -func TestAnAccountsNameInTheDefaultsIsNeverManaged(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - links(t, nil) - f.write(meshDefaults, string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))+ - `pref("mail.identity.id1.useremail", "x");`+"\n"+"// keeps Betterbird's default: mail.server.server1.hostname\n"+ - `pref("mail.smtpservers", "");`+"\n") - prefs := `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + - `user_pref("mail.server.server1.hostname", "imap.example.org");` + "\n" + - `user_pref("mail.smtpservers", "smtp1");` + "\n" - f.write(profileDir+"/prefs.js", prefs) - c, err := f.Config(nil) - if err != nil { - t.Fatal(err) - } - if len(c.Refused) != 3 || len(c.Drifting) != 0 { - t.Fatalf("refused, never managed: %+v", c) - } - raw, _ := json.Marshal(c) - if strings.Contains(string(raw), "example.org") { - t.Fatalf("no account value is answered: %s", raw) - } - stopBetterbird(t, f) - if _, err := f.ConfigApply(true); err != nil { - t.Fatal(err) - } - if f.read(profileDir+"/prefs.js") != prefs { - t.Fatal("prefs.js keeps every account's value") - } - ch, _ := f.Check() - found := false - for _, x := range ch.Findings { - found = found || (strings.Contains(x.What, "3 preference(s) the tools never manage") && strings.Contains(x.Do, "mesh-defaults.js")) - } - if !found { - t.Fatalf("the check says so: %+v", ch.Findings) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/configuration.go b/modules/betterbird/cmd/betterbird-tools/configuration.go deleted file mode 100644 index 97e0ad52..00000000 --- a/modules/betterbird/cmd/betterbird-tools/configuration.go +++ /dev/null @@ -1,472 +0,0 @@ -package main - -// Betterbird's whole configuration, beside the accounts (novox/hq ADR 0329 §4–§6, issue 586). -// -// Five settings, each mesh-wide with a node's layer over it, reach the machine as two files the node-engine -// merges from the module's layers key by key: -// -// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as -// accounts is (§5), because each names where a password, a reply or forwarded mail goes; -// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751, -// so a change to it is the operator's to approve. -// -// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its -// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a -// calendar or an address book its `key`: the profile's key to adopt there (§4, §7). No value holds a password, -// a token or a cookie: a key shaped like one is refused whole, and so is an address whose user part carries a -// password (§2). A setting holds no line break (novox/hq issue 339), so a signature is a list of its lines. - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "regexp" - "sort" - "strings" -) - -const ( - // configurationFile holds identities, calendars, address-books and filters; tagsFile holds tags. - configurationFile = ".config/betterbird/configuration.json" - tagsFile = ".config/betterbird/tags.json" -) - -// The five kinds, in the order the tools answer them. -const ( - kindIdentities = "identities" - kindCalendars = "calendars" - kindAddressBooks = "address-books" - kindFilters = "filters" - kindTags = "tags" -) - -var allKinds = []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters, kindTags} - -// Identity is one identity of a declared account. The account's default identity is the entry that gives no -// address: its name and address are the ones the setting accounts gives (§4). Any other entry is another -// identity of that account, matched by its address. -type Identity struct { - Account string `json:"account"` - Address string `json:"address,omitempty"` - Name string `json:"name,omitempty"` - ReplyTo string `json:"reply-to,omitempty"` - Organization string `json:"organization,omitempty"` - // Signature is the signature's text, a line to an element. - Signature []string `json:"signature,omitempty"` - SignatureHTML bool `json:"signature-html,omitempty"` - // SignatureAboveQuote puts the reply and its signature above the quoted message (Betterbird's "start my - // reply above the quote", signature below the reply); false is below the quote. - SignatureAboveQuote bool `json:"signature-above-quote,omitempty"` - Key string `json:"key,omitempty"` -} - -// Calendar is one CalDAV calendar. -type Calendar struct { - Name string `json:"name"` - URL string `json:"url"` - Color string `json:"color,omitempty"` - ReadOnly bool `json:"read-only,omitempty"` - // Reminders absent is on. - Reminders *bool `json:"reminders,omitempty"` - User string `json:"user,omitempty"` - // Invitations is the identities entry whose address answers invitations; absent, none. - Invitations string `json:"invitations,omitempty"` - Key string `json:"key,omitempty"` -} - -// AddressBook is one CardDAV address book. -type AddressBook struct { - Name string `json:"name"` - URL string `json:"url"` - User string `json:"user,omitempty"` - Key string `json:"key,omitempty"` -} - -// Tag is one tag, keyed in the setting by its own key. -type Tag struct { - Name string `json:"name"` - Color string `json:"color,omitempty"` - // Order is its place among the tags, from 1. - Order int `json:"order,omitempty"` -} - -// Filter is one message filter of an account, in its place in the account's list. -type Filter struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - // When it runs: new-mail, manual, after-junk, sending, archiving, periodic. - When []string `json:"when"` - // Match is all (every condition holds), any (one does) or every-message (no condition). - Match string `json:"match"` - Conditions []Condition `json:"conditions,omitempty"` - Actions []Action `json:"actions"` -} - -// Condition is one of a filter's conditions, in Betterbird's own words: a field, an operator, a value. -type Condition struct { - Field string `json:"field"` - Op string `json:"op"` - Value string `json:"value,omitempty"` -} - -// Action is one of a filter's actions, in Betterbird's own words. A folder is named by its path within the -// account (INBOX/Receipts), and becomes that folder's address on each node (§8). -type Action struct { - Action string `json:"action"` - Folder string `json:"folder,omitempty"` - Value string `json:"value,omitempty"` -} - -// Configuration is the five settings as the machine has them. -type Configuration struct { - Identities map[string]*Identity `json:"identities,omitempty"` - Calendars map[string]*Calendar `json:"calendars,omitempty"` - AddressBooks map[string]*AddressBook `json:"address-books,omitempty"` - Filters map[string][]Filter `json:"filters,omitempty"` - Tags map[string]*Tag `json:"tags,omitempty"` -} - -var ( - entryName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`) - tagKey = regexp.MustCompile(`^[$A-Za-z0-9_.\-]{1,64}$`) - colorWord = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) - // secretName is a field's name that says it holds a credential: such a field is withheld from every answer (§2). - secretName = regexp.MustCompile(`(?i)(password|passwd|token|secret|cookie)`) - // userPassword is an address whose user part carries a password: scheme://user:password@host. - userPassword = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://[^/:@\s]*):[^@/\s]+@`) -) - -// The words a filter is written in. -var ( - filterWhen = map[string]int{"new-mail": 0x1, "manual": 0x10, "after-junk": 0x20, "sending": 0x40, - "archiving": 0x80, "periodic": 0x100} - filterFields = map[string]bool{"subject": true, "from": true, "to": true, "cc": true, "to or cc": true, - "all addresses": true, "body": true, "date": true, "priority": true, "status": true, "age in days": true, - "size": true, "tag": true, "junk status": true, "junk percent": true, "junk score origin": true, - "has attachment status": true, "date received": true} - filterOps = map[string]bool{"contains": true, "doesn't contain": true, "is": true, "isn't": true, - "begins with": true, "ends with": true, "is empty": true, "isn't empty": true, "is before": true, - "is after": true, "is greater than": true, "is less than": true, "is higher than": true, - "is lower than": true, "is in ab": true, "isn't in ab": true, "matches": true, "doesn't match": true} - // filterActions are the actions a declared filter may take, and whether each names a folder or a value. - filterActions = map[string]string{"Move to folder": "folder", "Copy to folder": "folder", - "Change priority": "value", "Delete": "", "Mark read": "", "Mark unread": "", "Mark flagged": "", - "Ignore thread": "", "Ignore subthread": "", "Watch thread": "", "AddTag": "value", "JunkScore": "value", - "Forward": "value", "Stop execution": "", "Delete from Pop3 server": "", "Leave on Pop3 server": "", - "Fetch body from Pop3Server": ""} -) - -// readConfiguration reads the five settings. A missing file holds none of its kinds (the module is older -// here); a file that carries a key shaped like a credential, an address with a password, an unknown field or a -// value Betterbird cannot be given is refused whole, naming the entry and never a value. -func (m *Machine) readConfiguration() (Configuration, error) { - var c Configuration - var problems []string - for _, file := range []string{configurationFile, tagsFile} { - raw, err := readBounded(m.home(file)) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return Configuration{}, err - } - if err := decodeInto(raw, file, &c); err != nil { - problems = append(problems, err.Error()) - } - } - if len(problems) > 0 { - return Configuration{}, errors.New(strings.Join(problems, "; ")) - } - if p := configurationProblems(c); len(p) > 0 { - return Configuration{}, fmt.Errorf("the configuration is refused: %s", strings.Join(p, "; ")) - } - c.dropNulls() - for _, cal := range c.Calendars { - cal.Color = strings.ToLower(cal.Color) - } - for _, t := range c.Tags { - t.Color = strings.ToUpper(t.Color) - } - return c, nil -} - -// decodeInto reads one settings file into c, refusing a credential-shaped key or an unknown field. -func decodeInto(raw []byte, file string, c *Configuration) error { - var tree any - if err := json.Unmarshal(raw, &tree); err != nil { - return fmt.Errorf("~/%s is not JSON: %v", file, err) - } - if where := secretKeyIn(tree, ""); where != "" { - return fmt.Errorf("~/%s carries %s, a key shaped like a credential: the configuration is declared without "+ - "any password, token or cookie (novox/hq ADR 0329 §2); take it out at the controller's terminal", file, where) - } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.DisallowUnknownFields() - var into Configuration - if err := dec.Decode(&into); err != nil { - return fmt.Errorf("~/%s does not hold the shape of the settings it takes: %v", file, err) - } - if file == tagsFile { - if into.Identities != nil || into.Calendars != nil || into.AddressBooks != nil || into.Filters != nil { - return fmt.Errorf("~/%s holds only tags", file) - } - c.Tags = into.Tags - return nil - } - if into.Tags != nil { - return fmt.Errorf("~/%s holds no tags: tags are their own setting", file) - } - c.Identities, c.Calendars, c.AddressBooks, c.Filters = into.Identities, into.Calendars, into.AddressBooks, into.Filters - return nil -} - -// dropNulls takes out the entries a node's layer set to null: not on this node. -func (c *Configuration) dropNulls() { - for k, v := range c.Identities { - if v == nil { - delete(c.Identities, k) - } - } - for k, v := range c.Calendars { - if v == nil { - delete(c.Calendars, k) - } - } - for k, v := range c.AddressBooks { - if v == nil { - delete(c.AddressBooks, k) - } - } - for k, v := range c.Filters { - if v == nil { - delete(c.Filters, k) - } - } - for k, v := range c.Tags { - if v == nil { - delete(c.Tags, k) - } - } -} - -// addressPassword says whether an address carries a password in its user part. -func addressPassword(s string) bool { - if userPassword.MatchString(s) { - return true - } - if u, err := url.Parse(s); err == nil && u.User != nil { - _, has := u.User.Password() - return has - } - return false -} - -// withoutPassword is an address with the password of its user part as ‹withheld› (§2). -func withoutPassword(s string) string { - return userPassword.ReplaceAllString(s, "${1}:‹withheld›@") -} - -// davURL says what is wrong with a calendar's or an address book's address, or "". -func davURL(s string) string { - if addressPassword(s) { - return "its address carries a password in its user part, which no setting holds (novox/hq ADR 0329 §2): " + - "Betterbird asks for the password at the first sign-in" - } - u, err := url.Parse(s) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { - return "its address is an https:// (or http://) address of the server" - } - if strings.ContainsAny(s, lineEnds+`"\`) { - return "its address holds a quote, a backslash or a line break" - } - return "" -} - -// configurationProblems is everything wrong with the five settings, each said by its kind and entry name. -func configurationProblems(c Configuration) []string { - var out []string - say := func(kind, name, format string, args ...any) { - out = append(out, fmt.Sprintf("%s %s: ", kind, name)+fmt.Sprintf(format, args...)) - } - text := func(kind, name, field, v string) { - if !plainText.MatchString(v) { - say(kind, name, "%s holds a quote, a backslash or a line break", field) - } - } - for _, name := range sortedKeys(c.Identities) { - id := c.Identities[name] - if id == nil { - continue - } - if !entryName.MatchString(name) { - say(kindIdentities, name, "a name is lower-case letters and digits, starting with a letter") - } - if !accountName.MatchString(id.Account) { - say(kindIdentities, name, "account names an entry of the setting accounts") - } - if id.Address == "" && id.Name != "" { - say(kindIdentities, name, "the account's default identity (no address) takes its name from the setting accounts") - } - if id.Address != "" && !addressShape.MatchString(id.Address) { - say(kindIdentities, name, "the address is not a mail address") - } - if id.ReplyTo != "" && !addressShape.MatchString(id.ReplyTo) { - say(kindIdentities, name, "reply-to is not a mail address") - } - for field, v := range map[string]string{"name": id.Name, "organization": id.Organization} { - text(kindIdentities, name, field, v) - } - for _, line := range id.Signature { - if strings.ContainsAny(line, lineEnds) { - say(kindIdentities, name, "a signature's line holds a line break: each line is its own element") - break - } - } - if id.Key != "" && !profileKey.MatchString(id.Key) { - say(kindIdentities, name, "key is an identity key of the profile, such as id3") - } - } - for _, name := range sortedKeys(c.Calendars) { - cal := c.Calendars[name] - if cal == nil { - continue - } - if !entryName.MatchString(name) { - say(kindCalendars, name, "a name is lower-case letters and digits, starting with a letter") - } - if why := davURL(cal.URL); why != "" { - say(kindCalendars, name, "%s", why) - } - if cal.Color != "" && !colorWord.MatchString(cal.Color) { - say(kindCalendars, name, "a colour is #rrggbb") - } - if cal.Invitations != "" && c.Identities[cal.Invitations] == nil { - say(kindCalendars, name, "invitations names %s, which the setting identities does not hold", cal.Invitations) - } - text(kindCalendars, name, "name", cal.Name) - text(kindCalendars, name, "user", cal.User) - if cal.Key != "" && !tagKey.MatchString(cal.Key) { - say(kindCalendars, name, "key is a calendar key of the profile") - } - } - for _, name := range sortedKeys(c.AddressBooks) { - ab := c.AddressBooks[name] - if ab == nil { - continue - } - if !entryName.MatchString(name) { - say(kindAddressBooks, name, "a name is lower-case letters and digits, starting with a letter") - } - if why := davURL(ab.URL); why != "" { - say(kindAddressBooks, name, "%s", why) - } - text(kindAddressBooks, name, "name", ab.Name) - text(kindAddressBooks, name, "user", ab.User) - if ab.Key != "" && !profileKey.MatchString(ab.Key) { - say(kindAddressBooks, name, "key is an address book key of the profile") - } - } - for _, key := range sortedKeys(c.Tags) { - t := c.Tags[key] - if t == nil { - continue - } - if !tagKey.MatchString(key) { - say(kindTags, key, "a tag's key is letters, digits, $, _, . or -") - } - if t.Color != "" && !colorWord.MatchString(t.Color) { - say(kindTags, key, "a colour is #rrggbb") - } - text(kindTags, key, "name", t.Name) - } - for _, account := range sortedKeys(c.Filters) { - if !accountName.MatchString(account) { - say(kindFilters, account, "filters are keyed by an account's name in the setting accounts") - } - for i, f := range c.Filters[account] { - for _, p := range filterProblems(f) { - say(kindFilters, account, "filter %d: %s", i+1, p) - } - } - } - return out -} - -// filterValue is what a filter's name, value or folder may hold: what its file can carry without quoting. -var filterValue = regexp.MustCompile(`^[^"\\()\n\r]*$`) - -func filterProblems(f Filter) []string { - var out []string - if f.Name == "" || !filterValue.MatchString(f.Name) { - out = append(out, "a name, without a quote, a backslash or a parenthesis") - } - if len(f.When) == 0 { - out = append(out, "when says when it runs: new-mail, manual, after-junk, sending, archiving or periodic") - } - for _, w := range f.When { - if _, ok := filterWhen[w]; !ok { - out = append(out, fmt.Sprintf("when %q is new-mail, manual, after-junk, sending, archiving or periodic", w)) - } - } - switch f.Match { - case "all", "any": - if len(f.Conditions) == 0 { - out = append(out, "match all or any needs a condition") - } - case "every-message": - if len(f.Conditions) > 0 { - out = append(out, "match every-message takes no condition") - } - default: - out = append(out, "match is all, any or every-message") - } - for i, c := range f.Conditions { - if !filterFields[c.Field] && !(strings.HasPrefix(c.Field, `"`) && strings.HasSuffix(c.Field, `"`) && len(c.Field) > 2) { - out = append(out, fmt.Sprintf("condition %d: the field %q is not one Betterbird filters on", i+1, c.Field)) - } - if !filterOps[c.Op] { - out = append(out, fmt.Sprintf("condition %d: the operator %q is not one of Betterbird's", i+1, c.Op)) - } - if !filterValue.MatchString(c.Value) { - out = append(out, fmt.Sprintf("condition %d: the value holds a quote, a backslash, a parenthesis or a line break", i+1)) - } - } - if len(f.Actions) == 0 { - out = append(out, "a filter takes an action") - } - for i, a := range f.Actions { - takes, ok := filterActions[a.Action] - switch { - case !ok: - out = append(out, fmt.Sprintf("action %d: %q is not an action a declared filter takes", i+1, a.Action)) - case takes == "folder" && (a.Folder == "" || a.Value != ""): - out = append(out, fmt.Sprintf("action %d: %s names a folder, by its path within the account", i+1, a.Action)) - case takes == "value" && (a.Value == "" || a.Folder != ""): - out = append(out, fmt.Sprintf("action %d: %s takes a value", i+1, a.Action)) - case takes == "" && (a.Value != "" || a.Folder != ""): - out = append(out, fmt.Sprintf("action %d: %s takes no value", i+1, a.Action)) - } - if !filterValue.MatchString(a.Value) || !filterValue.MatchString(a.Folder) || strings.HasPrefix(a.Folder, "/") || - strings.Contains(a.Folder, "://") { - out = append(out, fmt.Sprintf("action %d: a folder is a path within the account, and a value holds no quote, "+ - "backslash, parenthesis or line break", i+1)) - } - if a.Action == "Forward" && !addressShape.MatchString(a.Value) { - out = append(out, fmt.Sprintf("action %d: Forward names a mail address", i+1)) - } - } - return out -} - -// sortedFilterAccounts are the accounts filters are declared for. -func (c Configuration) sortedFilterAccounts() []string { - out := make([]string, 0, len(c.Filters)) - for a := range c.Filters { - out = append(out, a) - } - sort.Strings(out) - return out -} diff --git a/modules/betterbird/cmd/betterbird-tools/configuration_test.go b/modules/betterbird/cmd/betterbird-tools/configuration_test.go deleted file mode 100644 index c9363119..00000000 --- a/modules/betterbird/cmd/betterbird-tools/configuration_test.go +++ /dev/null @@ -1,518 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// Betterbird's whole configuration (novox/hq ADR 0329, issue 586): identities, calendars, address books, filters -// and tags declared mesh-wide, adopted or created as accounts are, filters owned whole per declared account, what -// Betterbird rewrites written only while it is closed, derived from the nodes' profiles one layer at a time, and -// shown with the operator's own values but never a credential. - -// profileWithConfiguration adds to profileWithAccounts' profile: account1's directory and signature, CalDAV -// calendars (one address twice), an ICS and an Owl calendar, a local and a CardDAV address book, two tags, and -// account1's filter file holding one filter of the person's. -func (f *fake) profileWithConfiguration() { - f.t.Helper() - prefs := f.read(profileDir+"/prefs.js") + - `user_pref("mail.server.server1.directory-rel", "[ProfD]ImapMail/imap.example.org");` + "\n" + - `user_pref("mail.server.server2.directory-rel", "[ProfD]Mail/Local Folders");` + "\n" + - `user_pref("mail.identity.id1.htmlSigText", "Regards");` + "\n" + - `user_pref("calendar.registry.c1.type", "caldav");` + "\n" + - `user_pref("calendar.registry.c1.uri", "https://dav.example.org/cal/home/");` + "\n" + - `user_pref("calendar.registry.c1.name", "Home");` + "\n" + - `user_pref("calendar.registry.c1.color", "#3366ff");` + "\n" + - `user_pref("calendar.registry.c2.type", "caldav");` + "\n" + - `user_pref("calendar.registry.c2.uri", "https://dav.example.org/cal/shared/");` + "\n" + - `user_pref("calendar.registry.c2.name", "Shared A");` + "\n" + - `user_pref("calendar.registry.c3.type", "caldav");` + "\n" + - `user_pref("calendar.registry.c3.uri", "https://dav.example.org/cal/shared/");` + "\n" + - `user_pref("calendar.registry.c3.name", "Shared B");` + "\n" + - `user_pref("calendar.registry.c4.type", "ics");` + "\n" + - `user_pref("calendar.registry.c4.uri", "https://feeds.example.org/secret-token-abc.ics");` + "\n" + - `user_pref("calendar.registry.c4.name", "Holidays");` + "\n" + - `user_pref("calendar.registry.c5.type", "owl");` + "\n" + - `user_pref("calendar.registry.c5.uri", "owl://work@corp.example.com/calendar");` + "\n" + - `user_pref("calendar.registry.c5.name", "Work");` + "\n" + - `user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5");` + "\n" + - `user_pref("ldap_2.servers.pab.description", "Personal Address Book");` + "\n" + - `user_pref("ldap_2.servers.pab.dirType", 101);` + "\n" + - `user_pref("ldap_2.servers.ab1.description", "Contacts");` + "\n" + - `user_pref("ldap_2.servers.ab1.dirType", 102);` + "\n" + - `user_pref("ldap_2.servers.ab1.carddav.url", "https://dav.example.org/card/me/");` + "\n" + - `user_pref("ldap_2.servers.ab1.carddav.username", "me");` + "\n" + - `user_pref("mailnews.tags.$label1.tag", "Important");` + "\n" + - `user_pref("mailnews.tags.$label1.color", "#FF0000");` + "\n" + - `user_pref("mailnews.tags.personal.tag", "Personal");` + "\n" + - `user_pref("mailnews.tags.personal.color", "#00AA00");` + "\n" - f.write(profileDir+"/prefs.js", prefs) - f.write(profileDir+"/ImapMail/imap.example.org/"+filterFileName, "version=\"9\"\nlogging=\"no\"\nname=\"Old\"\n"+ - "enabled=\"yes\"\ntype=\"17\"\naction=\"Mark read\"\ncondition=\"AND (from,contains,news)\"\n") - f.write(profileDir+"/Mail/Local Folders/"+filterFileName, "version=\"9\"\nlogging=\"no\"\n") - f.write(profileDir+"/calendar-data/cache.sqlite", "events") -} - -const declaredConfiguration = `{ - "identities": { - "mail": {"account": "mail", "signature": ["-- ", "Other"], "signature-above-quote": true}, - "mailalias": {"account": "mail", "address": "alias@mail.example.org", "name": "Alias", "reply-to": "other@mail.example.org"} - }, - "calendars": { - "home": {"name": "Home", "url": "https://dav.example.org/cal/home", "color": "#3366CC", "user": "other", "invitations": "mail"}, - "work": {"name": "Team", "url": "https://dav.example.org/cal/team/"}, - "shared": {"name": "Shared", "url": "https://dav.example.org/cal/shared/"} - }, - "address-books": { - "contacts": {"name": "Contacts", "url": "https://dav.example.org/card/me/", "user": "other"}, - "team": {"name": "Team", "url": "https://dav.example.org/card/team/"} - }, - "filters": {} -}` - -const declaredTags = `{"tags": {"$label1": {"name": "Important", "color": "#ff0000", "order": 1}, "todo": {"name": "To do", "color": "#0000ff", "order": 2}}}` - -func (f *fake) declareConfiguration(body, tags string) { - f.write(testHome+"/"+configurationFile, body) - f.write(testHome+"/"+tagsFile, tags) -} - -func entriesBy(u UserJS) map[string]resolvedEntry { - out := map[string]resolvedEntry{} - for _, e := range u.Configuration { - out[e.Kind+"/"+e.Name] = e - } - return out -} - -// §2, §4, §6: the settings' shapes; a credential, an address with a password, a key on a tag and the four new -// prefixes in prefs are refused, said without the value. -func TestTheSettingsRefuseACredentialAPasswordedAddressATagsKeyAndTheirPrefixesInPrefs(t *testing.T) { - f := newFake(t) - f.declareConfiguration(declaredConfiguration, declaredTags) - c, err := f.readConfiguration() - if err != nil || len(c.Identities) != 2 || len(c.Calendars) != 3 || len(c.AddressBooks) != 2 || len(c.Tags) != 2 { - t.Fatalf("the declared shapes are taken: %+v %v", c, err) - } - if c.Calendars["home"].Color != "#3366cc" || c.Tags["todo"].Color != "#0000FF" { - t.Errorf("colours are said as Betterbird keeps them: %+v %+v", c.Calendars["home"], c.Tags["todo"]) - } - for _, bad := range []struct{ conf, tags, says string }{ - {`{"calendars": {"home": {"name": "H", "url": "https://dav.example.org/c/", "password": "hunter2"}}}`, `{}`, "shaped like a credential"}, - {`{"calendars": {"home": {"name": "H", "url": "https://me:hunter2@dav.example.org/c/"}}}`, `{}`, "carries a password"}, - {`{"address-books": {"b": {"name": "B", "url": "https://me:hunter2@dav.example.org/b/"}}}`, `{}`, "carries a password"}, - {`{}`, `{"tags": {"todo": {"name": "To do", "key": "x"}}}`, "does not hold the shape"}, - {`{"tags": {"todo": {"name": "To do"}}}`, `{}`, "holds no tags"}, - {`{"identities": {"x": {"account": "mail", "name": "Me"}}}`, `{}`, "default identity"}, - {`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "all", "actions": [{"action": "Delete"}]}]}}`, `{}`, "needs a condition"}, - {`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "every-message", "actions": [{"action": "Move to folder", "folder": "imap://x@y/INBOX"}]}]}}`, `{}`, "path within the account"}, - } { - f.declareConfiguration(bad.conf, bad.tags) - _, err := f.readConfiguration() - if err == nil || !strings.Contains(err.Error(), bad.says) || strings.Contains(err.Error(), "hunter2") { - t.Errorf("%s %s: %v", bad.conf, bad.tags, err) - } - } - // A key is an identity's, a calendar's or an address book's. - f.declareConfiguration(`{"identities": {"x": {"account": "mail", "address": "x@mail.example.org", "key": "id9"}}, - "calendars": {"c": {"name": "C", "url": "https://dav.example.org/c/", "key": "c9"}}, - "address-books": {"b": {"name": "B", "url": "https://dav.example.org/b/", "key": "ab9"}}}`, `{}`) - if _, err := f.readConfiguration(); err != nil { - t.Fatal(err) - } - // prefs refuses the four prefixes the new settings own, and the account's and a credential's as before. - for _, name := range []string{"calendar.registry.c1.color", "calendar.list.sortOrder", "ldap_2.servers.ab1.description", - "mailnews.tags.$label1.color", "mail.identity.id1.htmlSigText", "signon.rememberSignons"} { - body, _ := json.Marshal(map[string]any{"prefs": map[string]any{name: "x"}}) - if _, err := parseDeclaredPrefs(body); err == nil || !strings.Contains(err.Error(), name) { - t.Errorf("%s: %v", name, err) - } - } - got, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14, "mail.chat.enabled": false, "calendar.alarms.show": null, - "intl.date_time.pattern_override.date_short": "yyyy-MM-dd"}}`)) - if err != nil || !reflect.DeepEqual(got, map[string]string{"mail.uifontsize": "14", "mail.chat.enabled": "false", - "intl.date_time.pattern_override.date_short": `"yyyy-MM-dd"`}) { - t.Fatalf("one entry per preference, null taking one out: %v %v", got, err) - } - if _, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14.5}}`)); err == nil { - t.Fatal("a number Betterbird does not keep is refused") - } -} - -// §7, §9: each kind is adopted, created or refused as accounts are; the declared fields go into the user.js, and -// what Betterbird rewrites waits for it to close; leaving the setting releases or removes, never events. -func TestEachKindIsAdoptedOrCreatedAndWhatBetterbirdRewritesWaitsForItToClose(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(adoptable) - f.declareConfiguration(declaredConfiguration, declaredTags) - before := f.read(profileDir + "/prefs.js") - - // Betterbird runs: the user.js is placed, prefs.js and the filter files wait (§9). - u, err := f.userJS(true) - if err != nil { - t.Fatal(err) - } - by := entriesBy(u) - for want, how := range map[string][2]string{ - "identities/mail": {adopted, "id1"}, "identities/mailalias": {created, "ident_mesh_mailalias"}, - "calendars/home": {adopted, "c1"}, "calendars/work": {created, "mesh_work"}, "calendars/shared": {refused, ""}, - "address-books/contacts": {adopted, "ab1"}, "address-books/team": {created, "mesh_team"}, - "tags/$label1": {adopted, "$label1"}, "tags/todo": {created, "todo"}, - } { - if e := by[want]; e.How != how[0] || e.Key != how[1] { - t.Errorf("%s: %+v, want %v", want, e, how) - } - } - if why := by["calendars/shared"].Why; !strings.Contains(why, "2 entries") || !strings.Contains(why, "c2, c3") || - !strings.Contains(why, "key in this node's layer") { - t.Errorf("an ambiguous match is refused with the keys and the remedy: %s", why) - } - placed := f.read(profileDir + "/user.js") - for _, want := range []string{ - `user_pref("mail.identity.id1.htmlSigText", "-- \nOther");`, - `user_pref("mail.identity.id1.reply_on_top", 1);`, - `user_pref("mail.identity.id1.sig_bottom", false);`, - `user_pref("mail.identity.ident_mesh_mailalias.useremail", "alias@mail.example.org");`, - `user_pref("mail.identity.ident_mesh_mailalias.smtpServer", "smtp1");`, - `user_pref("calendar.registry.c1.color", "#3366cc");`, - `user_pref("calendar.registry.c1.imip.identity.key", "id1");`, - `user_pref("calendar.registry.mesh_work.type", "caldav");`, - `user_pref("ldap_2.servers.ab1.carddav.username", "other");`, - `user_pref("ldap_2.servers.mesh_team.dirType", 102);`, - `user_pref("mailnews.tags.todo.color", "#0000FF");`, - `user_pref("mailnews.tags.todo.ordinal", "1002");`, - } { - if !strings.Contains(placed, want) { - t.Errorf("the user.js lacks %s", want) - } - } - for _, never := range []string{"calendar.registry.c2.", "calendar.registry.c3.", "calendar.registry.c4.", "calendar.registry.c5.", - "ldap_2.servers.pab.", "mailnews.tags.personal.", "calendar.list.sortOrder"} { - if strings.Contains(placed, never) { - t.Errorf("the user.js writes %s, which nothing declares or which is refused", never) - } - } - if !strings.HasPrefix(u.Closed, "not written") || !strings.Contains(u.Closed, "running") || - u.Waiting[kindIdentities] != 1 || u.Waiting[kindCalendars] != 1 { - t.Fatalf("while Betterbird runs prefs.js is not written, and the waiting changes are counted: %q %v", u.Closed, u.Waiting) - } - if f.read(profileDir+"/prefs.js") != before { - t.Fatal("prefs.js changed while Betterbird runs") - } - ch, _ := f.Check() - waits := false - for _, x := range ch.Findings { - waits = waits || (strings.Contains(x.What, "change(s) to calendars are not written yet") && strings.Contains(x.Do, "betterbird_restart")) - } - if !waits { - t.Fatalf("the check counts the waiting changes per kind: %+v", ch.Findings) - } - - // A key in the node's layer settles the ambiguous calendar; one the profile does not hold is refused. - f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, - `"url": "https://dav.example.org/cal/shared/", "key": "c3"}`, 1), declaredTags) - if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].Key != "c3" || entriesBy(u)["calendars/shared"].How != adopted { - t.Fatalf("the named key is adopted: %+v", entriesBy(u)["calendars/shared"]) - } - f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, - `"url": "https://dav.example.org/cal/shared/", "key": "c9"}`, 1), declaredTags) - if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused || !strings.Contains(entriesBy(u)["calendars/shared"].Why, "c9") { - t.Fatalf("a key the profile does not hold is refused: %+v", entriesBy(u)["calendars/shared"]) - } - f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, - `"url": "https://dav.example.org/cal/shared/", "key": "c1"}`, 1), declaredTags) - if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused { - t.Fatalf("a key whose entry does not match is refused: %+v", entriesBy(u)["calendars/shared"]) - } - f.declareConfiguration(declaredConfiguration, declaredTags) - - // Closed: the lists gain the module's own keys at their end, every other line as it was. - stopBetterbird(t, f) - f.write(profileDir+"/lock", "") - if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "locked") { - t.Fatalf("the profile's lock holds the writes back: %v", err) - } - os.Remove(filepath.Join(f.Root, profileDir, "lock")) - if err := f.Prepare(); err != nil && !strings.Contains(err.Error(), "calendars shared is refused") { - t.Fatal(err) - } - after := f.read(profileDir + "/prefs.js") - for _, want := range []string{`user_pref("mail.account.account1.identities", "id1,ident_mesh_mailalias");`, - `user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5 mesh_work");`} { - if !strings.Contains(after, want) { - t.Errorf("prefs.js lacks %s:\n%s", want, after) - } - } - bl, al := strings.Split(before, "\n"), strings.Split(after, "\n") - for i := range bl { - if bl[i] != al[i] && !strings.Contains(bl[i], ".identities") && !strings.Contains(bl[i], "sortOrder") { - t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i]) - } - } - - // Leaving: an adopted entry is released (its lines go, the profile keeps it), a created one's preferences are - // removed; the calendar's events stay. - f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+ - `user_pref("calendar.registry.mesh_work.calendarSyncState", "x");`+"\n"+`user_pref("mailnews.tags.todo.tag", "To do");`+"\n") - f.declareConfiguration(`{"identities": {"mail": {"account": "mail"}}}`, `{"tags": {}}`) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - final := f.read(profileDir + "/prefs.js") - for _, gone := range []string{"mesh_work", "ident_mesh_mailalias", "mailnews.tags.todo."} { - if strings.Contains(final, gone) { - t.Errorf("what the module created and the setting left is removed: %s is still there", gone) - } - } - for _, kept := range []string{`calendar.registry.c1.uri`, `ldap_2.servers.ab1.carddav.url`, `mailnews.tags.$label1.tag`, - `calendar.registry.c4.uri`} { - if !strings.Contains(final, kept) { - t.Errorf("released or undeclared, %s stays the person's", kept) - } - } - if f.read(profileDir+"/calendar-data/cache.sqlite") != "events" { - t.Error("a calendar's events are never touched") - } - if placed := f.read(profileDir + "/user.js"); strings.Contains(placed, "calendar.registry.c1.") || strings.Contains(placed, "ab1") { - t.Errorf("a released entry's lines leave the user.js:\n%s", placed) - } -} - -const declaredFilters = `{"filters": {"mail": [ - {"name": "Receipts", "enabled": true, "when": ["new-mail", "manual"], "match": "all", - "conditions": [{"field": "subject", "op": "contains", "value": "receipt"}], - "actions": [{"action": "Move to folder", "folder": "INBOX/Receipts"}, {"action": "Forward", "value": "archive@mail.example.org"}]}, - {"name": "Everything", "enabled": false, "when": ["manual"], "match": "every-message", "actions": [{"action": "Mark read"}]} -]}}` - -const renderedFilters = "version=\"9\"\nlogging=\"no\"\n" + - "name=\"Receipts\"\nenabled=\"yes\"\ntype=\"17\"\naction=\"Move to folder\"\n" + - "actionValue=\"imap://other2@imap.example.org/INBOX/Receipts\"\naction=\"Forward\"\nactionValue=\"archive@mail.example.org\"\n" + - "condition=\"AND (subject,contains,receipt)\"\n" + - "name=\"Everything\"\nenabled=\"no\"\ntype=\"16\"\naction=\"Mark read\"\ncondition=\"ALL\"\n" - -// §8: an account named in filters has its msgFilterRules.dat written whole from the list, the replaced file kept; -// an account never named keeps its file byte for byte; one taken out keeps the file it last had. -func TestFiltersAreOwnedWholePerDeclaredAccount(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(adoptable) - f.declareConfiguration(declaredFilters, `{"tags": {}}`) - filters := profileDir + "/ImapMail/imap.example.org/" + filterFileName - local := profileDir + "/Mail/Local Folders/" + filterFileName - old, oldLocal := f.read(filters), f.read(local) - - u, err := f.userJS(true) - if err != nil { - t.Fatal(err) - } - if u.Waiting[kindFilters] != 1 || f.read(filters) != old { - t.Fatalf("while Betterbird runs the filter file waits: %v", u.Waiting) - } - stopBetterbird(t, f) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - if got := f.read(filters); got != renderedFilters { - t.Fatalf("the file is the declared list, its folder this node's address:\n%s", got) - } - keptPath := testHome + "/.config/betterbird/kept/60016jsp.default-default/mail." + filterFileName - if f.read(keptPath) != old { - t.Fatal("the replaced file is kept in the module's own directory") - } - if f.read(local) != oldLocal { - t.Fatal("an account never named keeps its file byte for byte") - } - if parsed := parseFilters(renderedFilters, "imap://other2@imap.example.org"); len(parsed.NotDeclarable) != 0 || - mustJSON(parsed.Filters) != mustJSON(mustConfig(t, declaredFilters).Filters["mail"]) { - t.Fatalf("the file reads back as the declared list: %+v", parsed) - } - - // A filter the person adds in Betterbird is drift first, and gone at the next apply; the kept file stays the first. - f.write(filters, renderedFilters+"name=\"Mine\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n") - d, _ := f.Drift(kindFilters) - if len(d.Kinds) != 1 || len(d.Kinds[0].Declared) != 1 || d.Kinds[0].Declared[0].State != "differs" || - !strings.Contains(mustJSON(d.Kinds[0].Declared[0].Differs), "Mine") { - t.Fatalf("drift shows the added filter: %+v", d) - } - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - if f.read(filters) != renderedFilters || f.read(keptPath) != old { - t.Fatal("the added filter is gone, and the kept file is still the one first replaced") - } - - // Taken out of filters: the module stops writing, the last file stays the person's, the kept copy is named. - f.declareConfiguration(`{"filters": {}}`, `{"tags": {}}`) - f.write(filters, renderedFilters+"name=\"Later\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n") - lastHad := f.read(filters) - if err := f.Prepare(); err != nil { - t.Fatal(err) - } - if f.read(filters) != lastHad || f.read(keptPath) != old { - t.Fatal("an account taken out keeps its file byte for byte, and the kept copy stays") - } - l, _ := f.List(kindFilters) - if l.Kept["mail"] != "~/.config/betterbird/kept/60016jsp.default-default/mail."+filterFileName { - t.Fatalf("betterbird_filters says where the kept copy is: %+v", l.Kept) - } - d, _ = f.Drift(kindFilters) - if len(d.Kinds[0].Declared) != 0 || len(d.Kinds[0].Undeclared) != 0 { - t.Fatalf("drift no longer counts that account's filters: %+v", d.Kinds[0]) - } -} - -func mustConfig(t *testing.T, body string) Configuration { - t.Helper() - var c Configuration - if err := decodeInto([]byte(body), configurationFile, &c); err != nil { - t.Fatal(err) - } - return c -} - -// §11: per kind and entry, the field that differs with both values; an undeclared entry is listed; once the module's -// user.js is placed, what it declares holds. -func TestDriftNamesEachDifferingFieldAndTheUndeclaredEntries(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.noPackage() - links(t, nil) - f.declare("// none") - f.declareAccounts(adoptable) - f.declareConfiguration(strings.Replace(declaredConfiguration, `"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},`, "", 1), declaredTags) - d, err := f.Drift("") - if err != nil { - t.Fatal(err) - } - by := map[string]KindDrift{} - for _, k := range d.Kinds { - by[k.Kind] = k - } - field := func(kind, name, f string) *fieldDiff { - for _, e := range by[kind].Declared { - if e.Name == name { - for _, x := range e.Differs { - if x.Field == f { - return &x - } - } - } - } - return nil - } - if x := field(kindIdentities, "mail", "signature"); x == nil || mustJSON(x.Profile) != `["Regards"]` { - t.Errorf("the signature differs, with both values: %+v", by[kindIdentities]) - } - if x := field(kindCalendars, "home", "color"); x == nil || x.Declared != "#3366cc" || x.Profile != "#3366ff" { - t.Errorf("the colour differs: %+v", by[kindCalendars]) - } - if x := field(kindAddressBooks, "contacts", "user"); x == nil || x.Profile != "me" { - t.Errorf("the user differs: %+v", by[kindAddressBooks]) - } - states := map[string]string{} - for _, k := range d.Kinds { - for _, e := range k.Declared { - states[k.Kind+"/"+e.Name] = e.State - } - } - if states["tags/todo"] != "lacks" || states["calendars/shared"] != refused || states["tags/$label1"] != "holds" || - states["address-books/team"] != "lacks" { - t.Errorf("lacks, refused and holds: %v", states) - } - undeclared := mustJSON(d) - for _, want := range []string{`"key":"personal"`, `"key":"c2"`, `"key":"c3"`} { - if !strings.Contains(undeclared, want) { - t.Errorf("the undeclared entries are listed: lacks %s", want) - } - } - if _, err := f.userJS(true); err != nil { - t.Fatal(err) - } - d, _ = f.Drift("") - for _, k := range d.Kinds { - for _, e := range k.Declared { - if e.State == "differs" { - t.Errorf("once the user.js is placed, %s %s holds: %+v", k.Kind, e.Name, e) - } - } - } -} - -// §1, §2, §13: the listing tools answer the operator's own values, Owl's marked, an ICS calendar by its name only, -// and never a credential: through the tools, a password in an address and a field named for one are withheld. -func TestTheListingsShowTheValuesOwlsMarkedAnICSByNameAndNoCredential(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+ - `user_pref("calendar.registry.c6.type", "caldav");`+"\n"+ - `user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+ - `user_pref("calendar.registry.c6.name", "Old");`+"\n") - f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"https://dav.example.org","encryptedPassword":"hunter3"}]}`) - links(t, nil) - f.declare("// none") - f.declareAccounts(adoptable) - f.declareConfiguration(declaredConfiguration, declaredTags) - old := machine - machine = f.Machine - t.Cleanup(func() { machine = old }) - answers := map[string]string{} - for _, tool := range tools() { - if strings.HasPrefix(tool.Name, "betterbird_") && contains([]string{"betterbird_identities", "betterbird_calendars", - "betterbird_address_books", "betterbird_filters", "betterbird_tags", "betterbird_accounts", "betterbird_drift"}, tool.Name) { - out, err := tool.Run(map[string]any{}) - if err != nil { - t.Fatalf("%s: %v", tool.Name, err) - } - answers[tool.Name] = mustJSON(out) - } - } - cals := answers["betterbird_calendars"] - for _, shown := range []string{"https://dav.example.org/cal/home/", `"name":"Work","url":"owl://work@corp.example.com/calendar"`, - `"owl":true`, `"name":"Holidays"`} { - if !strings.Contains(cals, shown) { - t.Errorf("betterbird_calendars shows %s: %s", shown, cals) - } - } - if strings.Contains(cals, "secret-token") { - t.Error("an ICS calendar is listed by its name only") - } - for name, a := range answers { - if strings.Contains(a, "hunter2") || strings.Contains(a, "hunter3") { - t.Errorf("%s carries a credential: %s", name, a) - } - } - if !strings.Contains(cals, "https://other:‹withheld›@dav.example.org/cal/x/") { - t.Errorf("an address's password is shown as ‹withheld›: %s", cals) - } - for _, shown := range []string{`"signature":["Regards"]`, "other@mail.example.org"} { - if !strings.Contains(answers["betterbird_identities"], shown) { - t.Errorf("betterbird_identities shows %s", shown) - } - } - if got := mustJSON(credentialChecked(map[string]any{"name": "x", "password": "p", "nested": []any{map[string]any{"oauthToken": "t"}}})); got != - `{"name":"x","nested":[{"oauthToken":"‹withheld›"}],"password":"‹withheld›"}` { - t.Errorf("a field named for a credential is withheld: %s", got) - } - // The start's words reach the journal: a refusal names kinds, names and keys, never an address. - stopBetterbird(t, f) - err := f.Prepare() - if err == nil || !strings.Contains(err.Error(), "calendars shared is refused") || strings.Contains(err.Error(), "example.org") { - t.Fatalf("%v", err) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/copies_test.go b/modules/betterbird/cmd/betterbird-tools/copies_test.go deleted file mode 100644 index 5bb7bb38..00000000 --- a/modules/betterbird/cmd/betterbird-tools/copies_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy; -// this test, itself one of the copied files, holds them to one text wherever the siblings are present. - -import ( - "bytes" - "os" - "path/filepath" - "testing" -) - -var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"} - -func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) { - compared := 0 - for _, module := range carriers { - dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools") - if _, err := os.Stat(dir); err != nil { - continue - } - for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} { - mine, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - theirs, err := os.ReadFile(filepath.Join(dir, f)) - if err != nil || !bytes.Equal(mine, theirs) { - t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) - } - } - compared++ - } - if compared == 0 { - t.Log("no sibling copies beside this module") - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/derive.go b/modules/betterbird/cmd/betterbird-tools/derive.go deleted file mode 100644 index 6d9b0484..00000000 --- a/modules/betterbird/cmd/betterbird-tools/derive.go +++ /dev/null @@ -1,700 +0,0 @@ -package main - -// The five settings derived from the desktop nodes' profiles and proposed for the operator's Approve, one layer -// at a time (novox/hq ADR 0329 §10, §12). -// -// betterbird_derive asks every desktop node that holds Betterbird for what its profile holds (its own derive with -// here), and lays the answers out per layer: an entry found on every such node goes into the mesh-wide layer, with -// the value of the node the derive was called on, and the answer lists every field another node holds differently, -// which then shows as drift there; an entry found on some nodes only goes into each of those nodes' layers, so -// nothing is added to another node until the operator moves it. With propose it proposes the next layer through -// the controller's settings verb: first the mesh-wide one, then each node's that needs one, each only once no -// proposal of the module's is open, against the layer as it then stands, which it carries whole beside the derived -// kinds (a layer is replaced whole). The ask shows the values in their shape (ADR 0277 §6); the operator reads them -// whole with `settings proposals ` at their desk. - -import ( - "encoding/json" - "errors" - "fmt" - "regexp" - "sort" - "strings" -) - -// derivedEntry is one entry a node's profile holds, in the setting's shape. Match is what makes two nodes' entries -// the same one: an identity's account and address, a calendar's or an address book's address, a tag's key, an -// account's name for its filters. -type derivedEntry struct { - Match string `json:"match"` - Name string `json:"name"` - Value json.RawMessage `json:"value"` -} - -// nodeDerivation is what one node's profile holds of the five kinds. -type nodeDerivation struct { - Node string `json:"node"` - Kinds map[string][]derivedEntry `json:"kinds"` - // NotDerived names what a setting does not hold, by name and why: Owl's, an ICS calendar, a local address - // book, an address carrying a password, a filter a setting cannot say. - NotDerived []string `json:"not_derived,omitempty"` -} - -var nameChars = regexp.MustCompile(`[^a-z0-9]`) - -// entryNameFor is a name of the operator's kind for an entry: lower-case letters and digits from its words, -// unique among those used. -func entryNameFor(words string, fallback string, used map[string]bool) string { - base := nameChars.ReplaceAllString(strings.ToLower(words), "") - if base == "" || base[0] < 'a' || base[0] > 'z' { - base = fallback + base - } - if len(base) > 28 { - base = base[:28] - } - name := base - for i := 2; used[name]; i++ { - name = fmt.Sprintf("%s%d", base, i) - } - used[name] = true - return name -} - -func raw(v any) json.RawMessage { - b, _ := json.Marshal(v) - return b -} - -// deriveHere reads this node's profile into the five kinds. -func (m *Machine) deriveHere(kinds []string) (nodeDerivation, error) { - d := nodeDerivation{Node: nodeName(), Kinds: map[string][]derivedEntry{}} - p, err := m.profile() - if err != nil { - return d, err - } - prefs := mergedPrefs(p.path) - accounts, _, err := m.readAccounts() - if err != nil { - return d, err - } - nameOf := map[string]string{} - serverOf := map[string]string{} - for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) { - if r.How != refused { - nameOf[r.Keys.Account], serverOf[r.name] = r.name, r.Keys.Server - } - } - want := map[string]bool{} - for _, k := range kinds { - want[k] = true - } - // Identities, by their account's name; the default carries no name or address (the setting accounts' own). - identityName := map[string]string{} - used := map[string]bool{} - byAccount := map[string]bool{} - for _, pi := range m.profileIdentities(prefs) { - account, declared := nameOf[pi.AccountKey] - switch { - case pi.Owl: - if pi.Default { - d.NotDerived = append(d.NotDerived, fmt.Sprintf("identities: %s <%s> is Owl's (novox/hq ADR 0316 §13)", - pi.Value.Name, pi.Value.Address)) - } - continue - case !declared: - if !byAccount[pi.AccountKey] { - byAccount[pi.AccountKey] = true - d.NotDerived = append(d.NotDerived, fmt.Sprintf("identities: those of the profile's account %s, which the "+ - "setting accounts does not hold", pi.AccountKey)) - } - continue - } - v := pi.Value - v.Account = account - e := derivedEntry{} - if pi.Default { - v.Address, v.Name = "", "" - e.Match, e.Name = account+"|", entryNameFor(account, "id", used) - } else { - local, _, _ := strings.Cut(v.Address, "@") - e.Match, e.Name = account+"|"+strings.ToLower(v.Address), entryNameFor(account+local, "id", used) - } - identityName[pi.Key] = e.Name - e.Value = raw(v) - if want[kindIdentities] { - d.Kinds[kindIdentities] = append(d.Kinds[kindIdentities], e) - } - } - // Calendars: CalDAV only; Owl's are listed, an ICS one by its name only, a local one by its name (§13). - used = map[string]bool{} - for _, pc := range profileCalendars(prefs) { - if !want[kindCalendars] { - break - } - switch { - case pc.Kind == "owl" || strings.HasPrefix(pc.Kind, "owl"): - d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s (%s) is Owl's", pc.Value.Name, pc.Value.URL)) - continue - case pc.Kind == "ics": - d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s is subscribed by an ICS address, which is "+ - "often itself the secret: listed by its name only, never declared (novox/hq ADR 0329 §13)", pc.Value.Name)) - continue - case pc.Kind != "caldav": - d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s is %s, kept in the profile: its events are never declared", - pc.Value.Name, pc.Kind)) - continue - case addressPassword(pc.Value.URL): - d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s at %s carries a password in its address: refused "+ - "(novox/hq ADR 0329 §2)", pc.Value.Name, withoutPassword(pc.Value.URL))) - continue - } - v := pc.Value - v.Invitations = identityName[pc.IdentityKey] - d.Kinds[kindCalendars] = append(d.Kinds[kindCalendars], derivedEntry{Match: strings.ToLower(strings.TrimRight(v.URL, "/")), - Name: entryNameFor(v.Name, "calendar", used), Value: raw(v)}) - } - // Address books: CardDAV only. - used = map[string]bool{} - for _, b := range profileAddressBooks(prefs) { - if !want[kindAddressBooks] { - break - } - switch { - case !b.CardDAV: - d.NotDerived = append(d.NotDerived, fmt.Sprintf("address-books: %s is %s: its contacts are never declared", b.Value.Name, b.Kind)) - continue - case addressPassword(b.Value.URL): - d.NotDerived = append(d.NotDerived, fmt.Sprintf("address-books: %s at %s carries a password in its address: "+ - "refused (novox/hq ADR 0329 §2)", b.Value.Name, withoutPassword(b.Value.URL))) - continue - } - d.Kinds[kindAddressBooks] = append(d.Kinds[kindAddressBooks], derivedEntry{ - Match: strings.ToLower(strings.TrimRight(b.Value.URL, "/")), Name: entryNameFor(b.Value.Name, "book", used), Value: raw(b.Value)}) - } - // Tags, by their own key. - if want[kindTags] { - for _, t := range profileTags(prefs) { - d.Kinds[kindTags] = append(d.Kinds[kindTags], derivedEntry{Match: t.Key, Name: t.Key, Value: raw(t.Value)}) - } - } - // Filters of each declared account whose file holds one or more: an account with none keeps its file as the - // person's until a setting names it. - if want[kindFilters] { - for _, name := range sortedKeys(serverOf) { - dir := m.accountDirectory(p.path, prefs, serverOf[name]) - if dir == "" { - continue - } - text, err := readBounded(dir + "/" + filterFileName) - if err != nil { - continue - } - parsed := parseFilters(string(text), folderBase(prefs, serverOf[name])) - for _, why := range parsed.NotDeclarable { - d.NotDerived = append(d.NotDerived, "filters of "+name+": "+why) - } - if len(parsed.Filters) > 0 { - d.Kinds[kindFilters] = append(d.Kinds[kindFilters], derivedEntry{Match: name, Name: name, Value: raw(parsed.Filters)}) - } - } - } - return d, nil -} - -// difference is one field another node holds differently from the value the mesh-wide layer gets. -type difference struct { - Kind string `json:"kind"` - Entry string `json:"entry"` - Node string `json:"node"` - Field string `json:"field"` - MeshWide any `json:"mesh_wide"` - There any `json:"there"` -} - -// DeriveAnswer is what betterbird_derive answers its caller: the values it derived, which ADR 0329 §10 lets it show. -type DeriveAnswer struct { - // Here is this node's own derivation, when asked with here. - Here *nodeDerivation `json:"here,omitempty"` - // From are the desktop nodes derived from, and NotHeard those holding Betterbird that did not answer. - From []string `json:"derived_from,omitempty"` - NotHeard []string `json:"not_heard,omitempty"` - // Layers are the derived values per layer: "mesh-wide", then each node's own. - Layers map[string]map[string]any `json:"layers,omitempty"` - Differences []difference `json:"differences,omitempty"` - NotDerived map[string][]string `json:"not_derived,omitempty"` - // Next is the layer a propose proposes now, or why none. - Next string `json:"next,omitempty"` - Proposed string `json:"proposed,omitempty"` - Note string `json:"note,omitempty"` -} - -const meshWide = "mesh-wide" - -// Derive derives the kinds asked for (all five when none) from the desktop nodes, and with propose proposes the -// next layer. prefs are values for the setting prefs to carry in the mesh-wide layer (issue 586: the operator's -// setup moved out of the catalogue); layer names the layer to propose instead of the next in order. -func (m *Machine) Derive(kinds []string, here, propose bool, prefs map[string]any, layer string) (DeriveAnswer, error) { - var a DeriveAnswer - if len(kinds) == 0 { - kinds = allKinds - } - for _, k := range kinds { - if !contains(allKinds, k) { - return a, fmt.Errorf("kinds are %s; %q is not one", strings.Join(allKinds, ", "), k) - } - } - if here { - d, err := m.deriveHere(kinds) - if err != nil { - return a, err - } - a.Here = &d - return a, nil - } - self := nodeName() - if self == "" { - return a, errors.New("this runtime does not say which machine it serves (MESH_NODE), so the node derived on is unknown") - } - holders, err := holdersOfBetterbird() - if err != nil { - return a, err - } - derived := map[string]nodeDerivation{} - for _, n := range holders { - if n == self { - d, err := m.deriveHere(kinds) - if err != nil { - return a, fmt.Errorf("this node's profile: %v", err) - } - derived[n] = d - continue - } - d, err := deriveOn(n, kinds) - if err != nil { - a.NotHeard = append(a.NotHeard, n+": "+err.Error()) - continue - } - derived[n] = d - } - if _, ok := derived[self]; !ok { - return a, fmt.Errorf("%s does not hold Betterbird as the controller says: derive on a desktop node", self) - } - a.From = sortedKeys(derived) - a.Layers, a.Differences = layOut(self, derived, kinds) - a.NotDerived = map[string][]string{} - for n, d := range derived { - if len(d.NotDerived) > 0 { - a.NotDerived[n] = d.NotDerived - } - } - if len(a.NotDerived) == 0 { - a.NotDerived = nil - } - if len(prefs) > 0 { - a.Layers[meshWide]["prefs"] = prefs - } - a.Note = "the values each layer would get; an entry on every desktop node is mesh-wide with this node's value, one " + - "on some nodes only is in those nodes' own layers. With propose, the next layer is proposed for the operator's " + - "Approve, carrying the layer as it stands beside these kinds; nothing changes until then (novox/hq ADR 0329 §10)" - if !propose { - return a, nil - } - if len(a.NotHeard) > 0 { - return a, fmt.Errorf("nothing was proposed: %d desktop node(s) holding Betterbird did not answer (%s), and a "+ - "layer derived without them would put on every node what only some hold; derive again when they are awake", - len(a.NotHeard), strings.Join(a.NotHeard, "; ")) - } - if open, err := openProposals(); err != nil { - return a, fmt.Errorf("nothing was proposed: whether a proposal of the module's is open cannot be read: %v", err) - } else if len(open) > 0 { - return a, fmt.Errorf("nothing was proposed: the module's proposal %s waits for the operator; the next layer is "+ - "proposed once it is set or declined (novox/hq ADR 0329 §10, ADR 0277 §10)", strings.Join(open, ", ")) - } - order := []string{meshWide} - for _, n := range sortedKeys(a.Layers) { - if n != meshWide { - order = append(order, n) - } - } - if layer != "" { - if _, ok := a.Layers[layer]; !ok { - return a, fmt.Errorf("nothing was proposed: layer is %s", strings.Join(order, " or ")) - } - order = []string{layer} - } - for _, l := range order { - node := l - if l == meshWide { - node = "" - } - current, err := readLayer(node) - if err != nil { - return a, fmt.Errorf("nothing was proposed: %v", err) - } - next := withKinds(current, a.Layers[l]) - if l == meshWide { - if prefs, ok := next["prefs"]; ok { - body, _ := json.Marshal(map[string]any{"prefs": prefs}) - if _, err := parseDeclaredPrefs(body); err != nil { - return a, fmt.Errorf("nothing was proposed: %v", err) - } - } - } - if err := proposable(next, l == meshWide); err != nil { - return a, fmt.Errorf("nothing was proposed for %s: %v", l, err) - } - if mustJSON(next) == mustJSON(current) { - continue - } - a.Next = l - said, err := proposeLayer(node, next, true) - a.Proposed = m.maskValue(said) - if err != nil { - return a, err - } - a.Note = "proposed the " + l + " layer for the operator's Approve; the next layer is proposed by calling again " + - "once this one is set or declined" - return a, nil - } - a.Next = "none: every layer already holds what was derived" - return a, nil -} - -// proposable checks a layer's derived kinds as the module would read them, so the operator is never asked to set -// a value the module then refuses. A key to adopt names one node's profile, so the mesh-wide layer holds none (§4). -func proposable(layer map[string]any, mesh bool) error { - conf := map[string]any{} - for _, k := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters} { - if v, ok := layer[k]; ok { - conf[k] = v - } - } - var c Configuration - if err := decodeInto([]byte(mustJSON(conf)), configurationFile, &c); err != nil { - return err - } - if v, ok := layer[kindTags]; ok { - if err := decodeInto([]byte(mustJSON(map[string]any{kindTags: v})), tagsFile, &c); err != nil { - return err - } - } - // A node's layer may name an identity the mesh-wide layer holds for a calendar's invitations: that is judged - // where the layers meet, on the node. - var problems []string - if mesh { - for name, id := range c.Identities { - if id != nil && id.Key != "" { - problems = append(problems, "identities "+name+": a key is given in a node's own layer only") - } - } - for name, cal := range c.Calendars { - if cal != nil && cal.Key != "" { - problems = append(problems, "calendars "+name+": a key is given in a node's own layer only") - } - } - for name, ab := range c.AddressBooks { - if ab != nil && ab.Key != "" { - problems = append(problems, "address-books "+name+": a key is given in a node's own layer only") - } - } - } - for _, p := range configurationProblems(c) { - if !strings.Contains(p, "invitations names") { - problems = append(problems, p) - } - } - if len(problems) > 0 { - return errors.New(strings.Join(problems, "; ")) - } - return nil -} - -// layOut puts each kind's entries into the layers (§10). -func layOut(self string, derived map[string]nodeDerivation, kinds []string) (map[string]map[string]any, []difference) { - layers := map[string]map[string]any{meshWide: {}} - var diffs []difference - nodes := sortedKeys(derived) - for _, kind := range kinds { - type seen struct { - by map[string]derivedEntry - } - groups := map[string]*seen{} - var order []string - for _, n := range append([]string{self}, nodes...) { - for _, e := range derived[n].Kinds[kind] { - g := groups[e.Match] - if g == nil { - g = &seen{by: map[string]derivedEntry{}} - groups[e.Match] = g - order = append(order, e.Match) - } - if _, already := g.by[n]; !already { - g.by[n] = e - } - } - } - mesh := map[string]any{} - used := map[string]bool{} - for _, match := range order { - g := groups[match] - if len(g.by) == len(nodes) { - mine := g.by[self] - name := uniqueName(mine.Name, used) - var v any - _ = json.Unmarshal(mine.Value, &v) - mesh[name] = v - for _, n := range nodes { - if n == self { - continue - } - diffs = append(diffs, fieldDifferences(kind, name, n, mine.Value, g.by[n].Value)...) - } - } - } - for _, match := range order { - g := groups[match] - if len(g.by) == len(nodes) { - continue - } - for _, n := range sortedKeys(g.by) { - e := g.by[n] - if layers[n] == nil { - layers[n] = map[string]any{} - } - nodeKind, _ := layers[n][kind].(map[string]any) - if nodeKind == nil { - nodeKind = map[string]any{} - layers[n][kind] = nodeKind - } - var v any - _ = json.Unmarshal(e.Value, &v) - nodeKind[uniqueName(e.Name, used)] = v - } - } - if len(mesh) > 0 { - layers[meshWide][kind] = mesh - } - } - return layers, diffs -} - -func uniqueName(name string, used map[string]bool) string { - n := name - for i := 2; used[n]; i++ { - n = fmt.Sprintf("%s%d", name, i) - } - used[n] = true - return n -} - -// fieldDifferences are the fields a node holds differently from the mesh-wide value of one entry. -func fieldDifferences(kind, entry, node string, mesh, there json.RawMessage) []difference { - var a, b map[string]any - if json.Unmarshal(mesh, &a) != nil || json.Unmarshal(there, &b) != nil { - if mustJSON(json.RawMessage(mesh)) != mustJSON(json.RawMessage(there)) { - var x, y any - _ = json.Unmarshal(mesh, &x) - _ = json.Unmarshal(there, &y) - return []difference{{Kind: kind, Entry: entry, Node: node, Field: "the whole list", MeshWide: x, There: y}} - } - return nil - } - var out []difference - for _, f := range sortedKeys(mergeKeys(a, b)) { - if mustJSON(a[f]) != mustJSON(b[f]) { - out = append(out, difference{Kind: kind, Entry: entry, Node: node, Field: f, MeshWide: a[f], There: b[f]}) - } - } - return out -} - -func mergeKeys(a, b map[string]any) map[string]bool { - out := map[string]bool{} - for k := range a { - out[k] = true - } - for k := range b { - out[k] = true - } - return out -} - -// withKinds is a layer with the derived kinds in place of its own, every other key kept. -func withKinds(layer map[string]any, kinds map[string]any) map[string]any { - out := map[string]any{} - for k, v := range layer { - out[k] = v - } - for k, v := range kinds { - if k == "prefs" { - merged := map[string]any{} - if was, ok := out["prefs"].(map[string]any); ok { - for n, x := range was { - merged[n] = x - } - } - for n, x := range v.(map[string]any) { - merged[n] = x - } - out[k] = merged - continue - } - out[k] = v - } - return out -} - -func contains(list []string, s string) bool { - for _, x := range list { - if x == s { - return true - } - } - return false -} - -// readLayer is the module's layer as the controller holds it: the mesh-wide one for node "", else that node's. -func readLayer(node string) (map[string]any, error) { - args := map[string]any{"module": "betterbird"} - if node != "" { - args["node"] = node - } - raw, err := asker("seat:mesh-controller.settings", args) - if err != nil { - return nil, fmt.Errorf("the controller's settings verb did not answer: %v", err) - } - said, ok := verbSaid(raw) - if !ok { - return nil, fmt.Errorf("the controller did not answer the layer: %s", said) - } - // A missing layer is said in exactly these words; anything else must read as the layer's JSON. A proposal - // replaces the layer whole, so an answer read wrongly as empty would drop what the layer holds: refused, loudly. - text := strings.TrimSpace(said) - where := "the whole mesh" - if node != "" { - where = node - } - if strings.HasPrefix(text, "betterbird on "+where+": no layer") { - return map[string]any{}, nil - } - out := map[string]any{} - if err := json.NewDecoder(strings.NewReader(text)).Decode(&out); err != nil || !strings.HasPrefix(text, "{") { - first, _, _ := strings.Cut(text, "\n") - return nil, fmt.Errorf("the controller's answer for the layer of betterbird on %s cannot be read as the layer "+ - "(it begins %q), so nothing is proposed: a layer is replaced whole and would lose what it holds", where, first) - } - return out, nil -} - -// proposeLayer proposes a layer whole for the operator's Approve (ADR 0277); replace says a key the layer had -// and the proposal does not hold is meant to go, which a re-derive needs when an entry left every profile. -func proposeLayer(node string, values map[string]any, replace bool) (string, error) { - body, err := json.Marshal(values) - if err != nil { - return "", err - } - args := map[string]any{"module": "betterbird", "values": string(body), "propose": "true"} - if replace { - args["replace"] = "true" - } - if node != "" { - args["node"] = node - } - raw, err := asker("seat:mesh-controller.settings", args) - if err != nil { - return "", fmt.Errorf("the controller's settings verb did not answer: %v", err) - } - said, ok := verbSaid(raw) - if !ok { - return said, fmt.Errorf("the controller did not take the proposal: %s", said) - } - return said, nil -} - -var ( - proposalHead = regexp.MustCompile(`^(\S+)\s+(?:set|clear) betterbird on (.+?)\s+\(`) - waitingLine = regexp.MustCompile(`^\s+waiting for the operator until`) -) - -// openProposals are the module's proposals that wait for the operator, by id and layer. -func openProposals() ([]string, error) { - raw, err := asker("seat:mesh-controller.settings", map[string]any{"proposals": "true"}) - if err != nil { - return nil, err - } - said, ok := verbSaid(raw) - if !ok { - return nil, errors.New(said) - } - var out []string - current := "" - for _, line := range strings.Split(said, "\n") { - if mm := proposalHead.FindStringSubmatch(line); mm != nil { - current = mm[1] + " (" + mm[2] + ")" - continue - } - if !strings.HasPrefix(line, " ") { - current = "" - } - if current != "" && waitingLine.MatchString(line) { - out = append(out, current) - current = "" - } - } - return out, nil -} - -// holdersOfBetterbird are the nodes the controller has assigned the module to: the desktop nodes. -func holdersOfBetterbird() ([]string, error) { - raw, err := asker("seat:mesh-controller.nodes", map[string]any{}) - if err != nil { - return nil, fmt.Errorf("the controller's nodes verb did not answer: %v", err) - } - said, _ := verbSaid(raw) - var nodes []struct { - Name string `json:"name"` - } - if err := json.Unmarshal([]byte(said), &nodes); err != nil { - return nil, fmt.Errorf("the controller's nodes cannot be read: %v", err) - } - var out []string - for _, n := range nodes { - raw, err := asker("seat:mesh-controller.node", map[string]any{"node": n.Name}) - if err != nil { - return nil, fmt.Errorf("the controller's node verb did not answer for %s: %v", n.Name, err) - } - said, _ := verbSaid(raw) - for _, line := range strings.Split(said, "\n") { - if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "assigned:"); ok { - for _, mod := range strings.Split(rest, ",") { - if strings.TrimSpace(mod) == "betterbird" { - out = append(out, n.Name) - } - } - } - } - } - sort.Strings(out) - if len(out) == 0 { - return nil, errors.New("the controller names no node the module is assigned to") - } - return out, nil -} - -// deriveOn asks another node's betterbird for what its profile holds. -func deriveOn(node string, kinds []string) (nodeDerivation, error) { - raw, err := asker("betterbird.betterbird_derive@"+node, map[string]any{"here": true, "kinds": kinds}) - if err != nil { - return nodeDerivation{}, err - } - said, ok := verbSaid(raw) - if !ok { - return nodeDerivation{}, errors.New(said) - } - var a DeriveAnswer - if err := json.Unmarshal([]byte(said), &a); err != nil || a.Here == nil { - return nodeDerivation{}, fmt.Errorf("its answer cannot be read") - } - a.Here.Node = node - return *a.Here, nil -} diff --git a/modules/betterbird/cmd/betterbird-tools/derive_test.go b/modules/betterbird/cmd/betterbird-tools/derive_test.go deleted file mode 100644 index 549e98d9..00000000 --- a/modules/betterbird/cmd/betterbird-tools/derive_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "testing" -) - -// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a -// time, carrying the layer as it stands. - -// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the -// controller answers them; layers are the controller's layers, open the proposals that wait. -type derivingMesh struct { - t *testing.T - shanks *fake - layers map[string]map[string]any - open bool - proposed []map[string]any - asked []string - asleep bool - // garbled answers the layer in words that are not its JSON. - garbled bool -} - -func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) { - d.asked = append(d.asked, key) - args, _ := body.(map[string]any) - answer := func(ok bool, text string) (json.RawMessage, error) { - b, _ := json.Marshal(map[string]any{"ok": ok, "output": text}) - return b, nil - } - switch { - case key == "seat:mesh-controller.nodes": - return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`) - case key == "seat:mesh-controller.node": - if args["node"] == "novox" { - return answer(true, "novox\n assigned: postgres, nats\n") - } - return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"])) - case key == "betterbird.betterbird_derive@shanks": - if d.asleep { - return nil, errors.New("nats: no responders available for request") - } - here, err := d.shanks.deriveHere(stringsOf(args["kinds"])) - if err != nil { - d.t.Fatal(err) - } - text, _ := json.Marshal(credentialChecked(DeriveAnswer{Here: &here})) - return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}}) - case key == "seat:mesh-controller.settings" && args["proposals"] == "true": - if d.open { - return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+ - "scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n") - } - return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n") - case key == "seat:mesh-controller.settings" && args["propose"] == "true": - d.proposed = append(d.proposed, args) - return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12") - case key == "seat:mesh-controller.settings": - node, _ := args["node"].(string) - if d.garbled { - return answer(true, "Settings of betterbird on "+node+":\n accounts = {…}\n") - } - layer := d.layers[node] - if layer == nil { - where := node - if where == "" { - where = "the whole mesh" - } - return answer(true, "betterbird on "+where+": no layer — the module's definition says\nbetterbird on "+where+", every value") - } - b, _ := json.MarshalIndent(layer, "", " ") - return answer(true, string(b)+"\n approved by the operator\nbetterbird on the whole mesh, every value and where it comes from:\n") - } - d.t.Fatalf("asked %s %v", key, body) - return nil, nil -} - -func stringsOf(v any) []string { - switch x := v.(type) { - case []string: - return x - case []any: - var out []string - for _, s := range x { - out = append(out, s.(string)) - } - return out - } - return nil -} - -func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+ - `user_pref("calendar.registry.c6.type", "caldav");`+"\n"+ - `user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+ - `user_pref("calendar.registry.c6.name", "Old");`+"\n") - f.declareAccounts(adoptable) - links(t, nil) - f2 := newFake(t) - f2.profileWithAccounts() - f2.profileWithConfiguration() - // shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own. - f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+ - `user_pref("calendar.registry.s1.type", "caldav");`+"\n"+ - `user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+ - `user_pref("calendar.registry.s1.name", "Standup");`+"\n") - f2.declareAccounts(adoptable) - mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}} - oldNode, oldAsk := nodeName, asker - t.Cleanup(func() { nodeName, asker = oldNode, oldAsk }) - nodeName = func() string { return "g14" } - asker = mesh.ask - - a, err := f.Derive(nil, false, false, nil, "") - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 { - t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard) - } - whole := mustJSON(a) - mw := a.Layers[meshWide] - ids, _ := mw[kindIdentities].(map[string]any) - if mail, _ := ids["mail"].(map[string]any); mail == nil || mustJSON(mail["signature"]) != `["Regards"]` || mail["address"] != nil { - t.Errorf("an identity on both nodes is mesh-wide with this node's value, the default without its address: %v", ids) - } - cals, _ := mw[kindCalendars].(map[string]any) - if cals["home"] == nil || cals["standup"] != nil { - t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals) - } - if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") { - t.Errorf("an entry on one node only goes into that node's layer: %s", sh) - } - tags, _ := mw[kindTags].(map[string]any) - if l1, _ := tags["$label1"].(map[string]any); l1 == nil || l1["color"] != "#FF0000" { - t.Errorf("the tag is mesh-wide with this node's colour: %v", tags) - } - diffs := map[string]difference{} - for _, d := range a.Differences { - diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d - } - if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` { - t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences) - } - if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" { - t.Errorf("%+v", a.Differences) - } - for _, never := range []string{"secret-token", "hunter2", "owl://", `"Work"`} { - if strings.Contains(mustJSON(a.Layers), never) { - t.Errorf("no ICS address, credential or Owl calendar is derived: %s in %s", never, mustJSON(a.Layers)) - } - } - if !strings.Contains(whole, "Holidays") || !strings.Contains(whole, "‹withheld›") || strings.Contains(mustJSON(credentialChecked(a)), "hunter2") { - t.Errorf("what is left out is named, an ICS calendar by its name and a password as ‹withheld›: %v", a.NotDerived) - } - if len(mesh.proposed) != 0 { - t.Fatal("without propose nothing is proposed") - } - - // Propose: the mesh-wide layer first, as it stands with the derived kinds, and the operator's setup of prefs. - prefs := map[string]any{} - for n, v := range shippedDefaultsWant { - prefs[n], _ = prefValue(v) - } - a, err = f.Derive(nil, false, true, prefs, "") - if err != nil { - t.Fatal(err) - } - if len(mesh.proposed) != 1 || mesh.proposed[0]["node"] != nil || mesh.proposed[0]["replace"] != "true" || a.Next != meshWide { - t.Fatalf("the mesh-wide layer is proposed first: %v %s", mesh.proposed, a.Next) - } - var values map[string]any - _ = json.Unmarshal([]byte(mesh.proposed[0]["values"].(string)), &values) - if values["accounts"] == nil || values[kindIdentities] == nil || values[kindTags] == nil || values["prefs"] == nil { - t.Fatalf("the layer as it stands, with every derived kind and prefs: %v", values) - } - body, _ := json.Marshal(map[string]any{"prefs": values["prefs"]}) - if got, err := parseDeclaredPrefs(body); err != nil || !reflect.DeepEqual(got, shippedDefaultsWant) { - t.Fatalf("the operator's 21 preferences, one entry each: %v %v", got, err) - } - if !strings.Contains(a.Proposed, "sab12") { - t.Fatalf("%s", a.Proposed) - } - - // While it waits nothing more is proposed. - mesh.open = true - if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 { - t.Fatalf("one open proposal of the module's at a time: %v", err) - } - // Approved: the mesh-wide layer holds it, so the next layer is shanks's own. - mesh.open = false - mesh.layers[""] = values - a, err = f.Derive(nil, false, true, prefs, "") - if err != nil { - t.Fatal(err) - } - if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" || - !strings.Contains(mesh.proposed[1]["values"].(string), "standup") { - t.Fatalf("then the node's own layer: %v", mesh.proposed) - } - var shanks map[string]any - if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil { - t.Fatal(err) - } - mesh.layers["shanks"] = shanks - if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") { - t.Fatalf("nothing left to propose: %v %s", err, a.Next) - } - - // A desktop node that does not answer: derived without it, never proposed. - mesh.asleep = true - a, err = f.Derive(nil, false, true, nil, meshWide) - if err == nil || len(a.NotHeard) != 1 || !strings.Contains(err.Error(), "did not answer") || len(mesh.proposed) != 2 { - t.Fatalf("a layer is never proposed without a desktop node: %v %v", err, a.NotHeard) - } - - // A key to adopt is a node's: the mesh-wide layer's proposal refuses one. - if err := proposable(map[string]any{kindCalendars: map[string]any{"c": map[string]any{"name": "C", - "url": "https://dav.example.org/c/", "key": "c3"}}}, true); err == nil || !strings.Contains(err.Error(), "node's own layer") { - t.Fatalf("%v", err) - } -} - -// Review 121: a layer the controller answers in any other words than its JSON or "no layer" refuses the proposal, -// because a proposal replaces the layer whole and would drop accounts. -func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) { - f := newFake(t) - f.profileWithAccounts() - f.profileWithConfiguration() - f.declareAccounts(adoptable) - links(t, nil) - f2 := newFake(t) - f2.profileWithAccounts() - f2.declareAccounts(adoptable) - mesh := &derivingMesh{t: t, shanks: f2, garbled: true} - oldNode, oldAsk := nodeName, asker - t.Cleanup(func() { nodeName, asker = oldNode, oldAsk }) - nodeName = func() string { return "g14" } - asker = mesh.ask - if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") || - len(mesh.proposed) != 0 { - t.Fatalf("the derive refuses: %v %v", err, mesh.proposed) - } - if _, err := f.ProfileAccounts(true); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") || - len(mesh.proposed) != 0 { - t.Fatalf("betterbird_accounts refuses: %v %v", err, mesh.proposed) - } - if _, err := readLayer(""); err == nil { - t.Fatal("an unreadable layer is an error, never an empty layer") - } - mesh.garbled = false - if got, err := readLayer("shanks"); err != nil || len(got) != 0 { - t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/desktop.go b/modules/betterbird/cmd/betterbird-tools/desktop.go deleted file mode 100644 index 4f52391d..00000000 --- a/modules/betterbird/cmd/betterbird-tools/desktop.go +++ /dev/null @@ -1,601 +0,0 @@ -package main - -// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and -// holds them to one text): a tray application of the operator's graphical session, seen from the -// node's tool runtime (novox/hq ADR 0208). -// -// The runtime is a system service running as the operator account (ADR 0175): it has the account's -// uid and none of the session's environment. A tool that starts something on the desktop finds the -// session from a process of the account that carries DISPLAY (the window manager first), and starts -// the program under the account's own service manager with `systemd-run --user`, never as its own -// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts. -// -// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and -// its signals are injected, so the tests run against a fake /proc and a fake home. -// -// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended -// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read -// to at most MostRead. - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "syscall" - "time" -) - -// Bounds every command and read is held to. -const ( - CallTimeout = 10 * time.Second - MostOutput = 256 << 10 - MostRead = 16 << 20 -) - -// Output is what a command did. -type Output struct { - Stdout string - Stderr string - Code int - // Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error. - Err error - Cut bool -} - -// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err. -var ( - ErrNotInstalled = errors.New("not installed") - ErrTimedOut = errors.New("timed out") - // ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. - ErrNoSession = errors.New("no graphical session") -) - -// Runner runs one command with extra environment, within the context's deadline. Tests replace it. -type Runner func(ctx context.Context, env []string, name string, args ...string) Output - -// Machine is what the tools read and act on. -type Machine struct { - Root string // "" on the machine; a fake root in tests - Home string // the operator's home, as the machine names it - UID int - Run Runner - Kill func(pid int, sig syscall.Signal) error - Sleep func(time.Duration) - Now func() time.Time - Timeout time.Duration -} - -// NewMachine is the machine the bundle runs on. -func NewMachine() *Machine { - return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill, - Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout} -} - -// operatorHome is the account's home: what the runtime was told, else the process's own. -func operatorHome() string { - if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { - return h - } - h, _ := os.UserHomeDir() - return h -} - -func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } - -// home is a path under the operator's home, on this machine's filesystem. -func (m *Machine) home(rel ...string) string { - return filepath.Join(append([]string{m.Root, m.Home}, rel...)...) -} - -// tilde shows a path under the home as ~/…, so an answer does not carry the account's name. -func (m *Machine) tilde(p string) string { - if m.Home != "" && m.Home != "/" { - h := strings.TrimSuffix(m.Home, "/") - if p == h { - return "~" - } - if strings.HasPrefix(p, h+"/") { - return "~/" + strings.TrimPrefix(p, h+"/") - } - } - return p -} - -// cmd runs a command within the machine's timeout (or a shorter one). -func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output { - if timeout <= 0 || timeout > m.Timeout { - timeout = m.Timeout - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - return m.Run(ctx, env, name, args...) -} - -// failed names how a command failed, or answers nil when it ran and exited 0. -func failed(o Output, name string, args ...string) error { - switch { - case errors.Is(o.Err, ErrNotInstalled): - return fmt.Errorf("%s is not installed on this machine", name) - case errors.Is(o.Err, ErrTimedOut): - return fmt.Errorf("%s gave no answer in time and was ended", name) - case o.Err != nil: - return fmt.Errorf("%s did not run: %v", name, o.Err) - case o.Code != 0: - said := strings.TrimSpace(o.Stderr) - if said == "" { - said = strings.TrimSpace(o.Stdout) - } - if said == "" { - said = "and said nothing" - } - return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000)) - } - return nil -} - -func tail(s string, n int) string { - if len(s) <= n { - return s - } - return "…" + s[len(s)-n:] -} - -type capped struct { - b bytes.Buffer - cut bool -} - -func (c *capped) Write(p []byte) (int, error) { - if room := MostOutput - c.b.Len(); room < len(p) { - if room > 0 { - c.b.Write(p[:room]) - } - c.cut = true - return len(p), nil - } - return c.b.Write(p) -} - -func execRun(ctx context.Context, env []string, name string, args ...string) Output { - path, err := exec.LookPath(name) - if err != nil { - return Output{Code: 127, Err: ErrNotInstalled} - } - cmd := exec.CommandContext(ctx, path, args...) - cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...) - // Its own process group, so that ending it on a timeout ends what it started too. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { - if cmd.Process != nil { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - } - return nil - } - cmd.WaitDelay = 2 * time.Second - var out, errs capped - cmd.Stdout, cmd.Stderr = &out, &errs - err = cmd.Run() - o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut} - var exit *exec.ExitError - switch { - case err == nil: - case ctx.Err() == context.DeadlineExceeded: - o.Code, o.Err = 124, ErrTimedOut - case errors.As(err, &exit): - o.Code = exit.ExitCode() - default: - o.Code, o.Err = 127, err - } - return o -} - -// readBounded reads a file to at most MostRead bytes. -func readBounded(path string) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - return io.ReadAll(io.LimitReader(f, MostRead)) -} - -// Proc is one process of the account. -type Proc struct { - PID int `json:"pid"` - Command string `json:"command"` - // StartedIn is the unit or scope it runs in: the login session's scope when the session's start - // (dex, the window manager) started it, a mesh-… unit when a tool restarted it. - StartedIn string `json:"started_in,omitempty"` - Since string `json:"since,omitempty"` -} - -// procs are this account's processes named comm, oldest first. -func (m *Machine) procs(comm string) []Proc { - entries, err := os.ReadDir(m.path("/proc")) - if err != nil { - return nil - } - boot := m.bootTime() - var out []Proc - for _, e := range entries { - pid, err := strconv.Atoi(e.Name()) - if err != nil { - continue - } - dir := m.path(filepath.Join("/proc", e.Name())) - if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID { - continue - } - p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))} - if p.Command == "" { - p.Command = comm - } - if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" { - line := strings.Split(cg, "\n")[0] - p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:]) - } - if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok { - p.Since = t.UTC().Format(time.RFC3339) - } - out = append(out, p) - } - sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) - return out -} - -// procsOf are the account's processes named comm whose program is word. The kernel keeps 15 -// characters of a command name, so a longer name can share them with another program's: this bundle's -// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t). -// The program is the first word of the command line, or the second for a script run by its -// interpreter. An empty word keeps every process named comm. -func (m *Machine) procsOf(comm, word string) []Proc { - var out []Proc - for _, p := range m.procs(comm) { - f := strings.Fields(p.Command) - if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) { - out = append(out, p) - } - } - return out -} - -// uidOf is the real uid on a process's status, -1 when unreadable. -func (m *Machine) uidOf(dir string) int { - for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") { - if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" { - if n, err := strconv.Atoi(f[1]); err == nil { - return n - } - } - } - return -1 -} - -func (m *Machine) bootTime() int64 { - for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") { - if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" { - n, _ := strconv.ParseInt(f[1], 10, 64) - return n - } - } - return 0 -} - -// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot). -func startOf(stat string, boot int64) (time.Time, bool) { - i := strings.LastIndexByte(stat, ')') - if i < 0 || boot == 0 { - return time.Time{}, false - } - f := strings.Fields(stat[i+1:]) - if len(f) < 20 { - return time.Time{}, false - } - ticks, err := strconv.ParseInt(f[19], 10, 64) - if err != nil { - return time.Time{}, false - } - return time.Unix(boot+ticks/100, 0), true -} - -func readTrimmed(path string) string { - b, err := os.ReadFile(path) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} - -func exists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// Session is what a tool needs to start something on the operator's desktop. -type Session struct { - Display string `json:"display"` - XAuthority string `json:"xauthority,omitempty"` - Bus string `json:"bus,omitempty"` - RuntimeDir string `json:"runtime_dir,omitempty"` - From string `json:"found_in"` -} - -// sessionHolders are the processes whose environment is the session's, best first. -var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"} - -// session finds the account's graphical session, or ErrNoSession saying what it looked at. -func (m *Machine) session() (Session, error) { - entries, _ := os.ReadDir(m.path("/proc")) - best, bestRank := -1, len(sessionHolders)+1 - var env map[string]string - var from string - for _, e := range entries { - pid, err := strconv.Atoi(e.Name()) - if err != nil { - continue - } - dir := m.path(filepath.Join("/proc", e.Name())) - if m.uidOf(dir) != m.UID { - continue - } - raw, err := os.ReadFile(filepath.Join(dir, "environ")) - if err != nil { - continue - } - vars := parseEnviron(raw) - if vars["DISPLAY"] == "" { - continue - } - comm := readTrimmed(filepath.Join(dir, "comm")) - rank := len(sessionHolders) - for i, h := range sessionHolders { - if h == comm { - rank = i - } - } - if rank < bestRank || (rank == bestRank && pid > best) { - best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid) - } - } - if env == nil { - return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+ - "Is anyone logged in to the desktop?", ErrNoSession, m.UID) - } - s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"], - RuntimeDir: env["XDG_RUNTIME_DIR"], From: from} - if s.RuntimeDir == "" { - s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID) - } - if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) { - s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") - } - return s, nil -} - -// bus is the account's session bus environment, which a logged-in account has with or without a -// desktop: what a command needs to reach the user's service manager or a bus name. -func (m *Machine) bus() []string { - runtime := fmt.Sprintf("/run/user/%d", m.UID) - return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"} -} - -// Env is the session's variables, for a command that draws or speaks to the desktop. -func (s Session) Env() []string { - var env []string - for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, - {"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} { - if kv[1] != "" { - env = append(env, kv[0]+"="+kv[1]) - } - } - return env -} - -func parseEnviron(raw []byte) map[string]string { - env := map[string]string{} - for _, kv := range bytes.Split(raw, []byte{0}) { - if i := bytes.IndexByte(kv, '='); i > 0 { - env[string(kv[:i])] = string(kv[i+1:]) - } - } - return env -} - -// detach starts a long-lived program under the account's service manager, as a transient unit that -// carries the session's display. A unit left by an earlier start under the same name is stopped -// first, so the fixed name means at most one. -func (m *Machine) detach(s Session, unit string, argv ...string) error { - _ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service") - call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} - for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { - if kv[1] != "" { - call = append(call, "--setenv="+kv[0]+"="+kv[1]) - } - } - call = append(append(call, "--"), argv...) - return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...) -} - -// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still -// there after grace. It answers the pids that ended and those that had to be killed. -func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) { - var ps []Proc - for _, c := range comms { - ps = append(ps, m.procs(c)...) - } - return m.stopProcs(grace, ps) -} - -// stopProcs ends the processes given, as stop does. -func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) { - var pids []int - for _, p := range ps { - if m.Kill(p.PID, syscall.SIGTERM) == nil { - pids = append(pids, p.PID) - } - } - alive := func() []int { - var left []int - for _, pid := range pids { - if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) { - left = append(left, pid) - } - } - return left - } - step := 200 * time.Millisecond - for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step { - m.Sleep(step) - } - left := alive() - for _, pid := range left { - if m.Kill(pid, syscall.SIGKILL) == nil { - killed = append(killed, pid) - } - } - gone := map[int]bool{} - for _, pid := range left { - gone[pid] = true - } - for _, pid := range pids { - if !gone[pid] { - ended = append(ended, pid) - } - } - return ended, killed -} - -// waitFor waits up to d for a process of the account named comm, and answers what it found. -func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) } - -// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf). -func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc { - step := 250 * time.Millisecond - for waited := time.Duration(0); ; waited += step { - if p := m.procsOf(comm, word); len(p) > 0 || waited >= d { - return p - } - m.Sleep(step) - } -} - -// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none. -func desktopEntry(path string) map[string]string { - raw, err := readBounded(path) - if err != nil { - return nil - } - out := map[string]string{} - in := false - s := bufio.NewScanner(bytes.NewReader(raw)) - for s.Scan() { - l := strings.TrimSpace(s.Text()) - switch { - case strings.HasPrefix(l, "["): - in = l == "[Desktop Entry]" - case in && l != "" && !strings.HasPrefix(l, "#"): - if i := strings.IndexByte(l, '='); i > 0 { - out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:]) - } - } - } - return out -} - -// Autostart is what XDG autostart does with one entry: the account's file overrides the system's -// of the same name, and Hidden=true (or the GNOME switch off) means it is not started. -type Autostart struct { - Entry string `json:"entry"` - From string `json:"from"` - Exec string `json:"exec,omitempty"` - Starts bool `json:"starts"` - Because string `json:"because,omitempty"` -} - -// autostart resolves one XDG autostart entry by its file name, the account's directory first. -func (m *Machine) autostart(name string) Autostart { - a := Autostart{Entry: name} - user := m.home(".config", "autostart", name) - system := m.path(filepath.Join("/etc/xdg/autostart", name)) - var e map[string]string - switch { - case exists(user): - e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name)) - case exists(system): - e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name) - default: - a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart" - return a - } - a.Exec = e["Exec"] - switch { - case strings.EqualFold(e["Hidden"], "true"): - a.Because = "Hidden=true" - case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"): - a.Because = "X-GNOME-Autostart-enabled=false" - case a.Exec == "": - a.Because = "the entry has no Exec" - default: - a.Starts = true - } - return a -} - -// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named -// word, in the configuration and its config.d: a second start beside an autostart entry. -func (m *Machine) i3Starts(word string) []string { - files := []string{m.home(".config", "i3", "config")} - more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf")) - files = append(files, more...) - var out []string - for _, f := range files { - raw, err := readBounded(f) - if err != nil { - continue - } - for n, l := range strings.Split(string(raw), "\n") { - t := strings.TrimSpace(l) - if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") { - continue - } - for _, w := range strings.Fields(t)[1:] { - if filepath.Base(strings.Trim(w, `"'`)) == word { - out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t)) - break - } - } - } - } - return out -} - -// installed asks the package manager for one package's version; "" when it is not installed. -func (m *Machine) installed(pkg string) (string, error) { - o := m.cmd(0, nil, "pacman", "-Q", pkg) - if o.Err != nil { - return "", failed(o, "pacman", "-Q", pkg) - } - if o.Code != 0 { - return "", nil - } - f := strings.Fields(o.Stdout) - if len(f) < 2 { - return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout) - } - return f[1], nil -} - -// Finding is one thing a check found wrong, and what to do about it. -type Finding struct { - What string `json:"what"` - Do string `json:"do,omitempty"` -} diff --git a/modules/betterbird/cmd/betterbird-tools/desktop_test.go b/modules/betterbird/cmd/betterbird-tools/desktop_test.go deleted file mode 100644 index e17bb69d..00000000 --- a/modules/betterbird/cmd/betterbird-tools/desktop_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package main - -// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop -// application's bundle (copies_test.go). - -import ( - "context" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "testing" - "time" -) - -const testHome = "/home/operator" - -// fake is a machine with a fake root, a scripted Runner and signals that end fake processes. -type fake struct { - *Machine - t *testing.T - mu sync.Mutex - calls []string - answer func(name string, args []string) Output - // onStart is run when systemd-run starts something, to let a fake process appear. - onStart func(argv []string) - // stubborn pids ignore SIGTERM. - stubborn map[int]bool - signals []string -} - -func newFake(t *testing.T) *fake { - t.Helper() - root := t.TempDir() - f := &fake{t: t, stubborn: map[int]bool{}} - f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout, - Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }} - f.Run = func(_ context.Context, env []string, name string, args ...string) Output { - f.mu.Lock() - f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " "))) - f.mu.Unlock() - if name == "systemd-run" && f.onStart != nil { - for i, a := range args { - if a == "--" { - f.onStart(args[i+1:]) - } - } - } - if f.answer != nil { - return f.answer(name, args) - } - return Output{} - } - f.Kill = func(pid int, sig syscall.Signal) error { - f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String()) - if sig == syscall.SIGKILL || !f.stubborn[pid] { - return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid))) - } - return nil - } - f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n") - return f -} - -func (f *fake) write(path, content string) { - f.t.Helper() - p := filepath.Join(f.Root, path) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - f.t.Fatal(err) - } - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - f.t.Fatal(err) - } -} - -// proc adds a process of uid with a command name, argv, cgroup and environment. -func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) { - d := "/proc/" + strconv.Itoa(pid) + "/" - f.write(d+"comm", comm+"\n") - f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n") - f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00") - f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n") - f.write(d+"environ", strings.Join(env, "\x00")+"\x00") - // starttime (field 22) is 1000 ticks: 10 s after boot. - f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n") -} - -func (f *fake) desktopSession() { - f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority") - f.write("/run/user/1000/bus", "") -} - -func (f *fake) called(prefix string) bool { - for _, c := range f.calls { - if strings.HasPrefix(c, prefix) { - return true - } - } - return false -} - -func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) { - f := newFake(t) - f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope") - f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope") - f.proc(12, 1000, "other", []string{"other"}, "x.scope") - got := f.procs("worker") - if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" || - got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) { - t.Fatalf("%+v", got) - } -} - -func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) { - f := newFake(t) - f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope") - f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope") - f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope") - if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 { - t.Fatalf("%+v", got) - } - if got := f.procsOf("polychromatic-t", ""); len(got) != 3 { - t.Fatalf("%+v", got) - } - ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet")) - if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 { - t.Fatalf("ended %v; the tools' own process must stay", ended) - } -} - -func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) { - f := newFake(t) - if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") { - t.Fatalf("%v", err) - } - f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9") - f.desktopSession() - f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5") - s, err := f.session() - if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" || - !strings.Contains(s.From, "i3") { - t.Fatalf("%+v %v", s, err) - } -} - -func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.proc(20, 1000, "app", []string{"app"}, "s.scope") - f.proc(21, 1000, "app", []string{"app"}, "s.scope") - f.stubborn[21] = true - ended, killed := f.stop(time.Second, "app") - if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 { - t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals) - } - s, _ := f.session() - if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil { - t.Fatal(err) - } - want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome + - "/.Xauthority -- /usr/bin/app --background" - if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) { - t.Fatalf("%q", f.calls) - } -} - -func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) { - f := newFake(t) - if a := f.autostart("x.desktop"); a.Starts || a.Because == "" { - t.Fatalf("%+v", a) - } - f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n") - if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" { - t.Fatalf("%+v", a) - } - f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n") - if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" { - t.Fatalf("%+v", a) - } -} - -func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) { - f := newFake(t) - f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n") - f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n") - got := f.i3Starts("app") - if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" { - t.Fatalf("%q", got) - } -} - -func TestACommandThatFailsIsNamed(t *testing.T) { - if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") { - t.Fatal(err) - } - if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") { - t.Fatal(err) - } - if err := failed(Output{}, "true"); err != nil { - t.Fatal(err) - } -} - -func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut { - t.Fatalf("%+v", o) - } - if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled { - t.Fatalf("%+v", o) - } - o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero") - if !o.Cut || len(o.Stdout) != MostOutput { - t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout)) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/filters.go b/modules/betterbird/cmd/betterbird-tools/filters.go deleted file mode 100644 index 493ae3db..00000000 --- a/modules/betterbird/cmd/betterbird-tools/filters.go +++ /dev/null @@ -1,271 +0,0 @@ -package main - -// An account's filters, owned whole per declared account (novox/hq ADR 0329 §8). -// -// Betterbird keeps an account's filters in msgFilterRules.dat in the account's own directory: a header, then per -// filter its name, whether it is on, when it runs (a bit mask), its actions and one condition line. For an account -// the setting filters names, the module writes that file from the declared list, naming each folder by its path -// within the account and turning it into the folder's address on this node; a filter in the file the setting does -// not hold is gone after the apply. Before its first write on a node the module keeps the file it replaces, in its -// own directory in the home (ADR 0182). It writes only while Betterbird does not run, because Betterbird rewrites -// the file itself (§9). - -import ( - "bufio" - "fmt" - "net/url" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" -) - -// filterFileName is the file an account's filters are in. -const filterFileName = "msgFilterRules.dat" - -// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "". -func accountDirectory(profile string, prefs map[string]string, server string) string { - p := "mail.server." + server + "." - if rel := unquote(prefs[p+"directory-rel"]); strings.HasPrefix(rel, "[ProfD]") { - dir := filepath.Join(profile, filepath.FromSlash(strings.TrimPrefix(rel, "[ProfD]"))) - if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) { - return dir - } - return "" - } - return "" -} - -// folderBase is the address of an account's root folder on this node: imap://user@host for IMAP, mailbox:// -// for POP3 and local folders, the user's @ written %40 as Betterbird writes it. -func folderBase(prefs map[string]string, server string) string { - p := "mail.server." + server + "." - kind := unquote(prefs[p+"type"]) - host := unquote(prefs[p+"hostname"]) - user := unquote(prefs[p+"userName"]) - scheme := "mailbox" - switch kind { - case "imap": - scheme = "imap" - case "none": - host, user = "Local Folders", "nobody" - } - return scheme + "://" + strings.ReplaceAll(url.PathEscape(user), "@", "%40") + "@" + url.PathEscape(host) -} - -// folderAddress is a folder's address on this node from its path within the account. -func folderAddress(base, path string) string { - parts := strings.Split(strings.Trim(path, "/"), "/") - for i, p := range parts { - parts[i] = url.PathEscape(p) - } - return base + "/" + strings.Join(parts, "/") -} - -// folderPath is a folder's path within the account from its address, when the address is the account's. -func folderPath(base, address string) (string, bool) { - rest, ok := strings.CutPrefix(address, base+"/") - if !ok { - return "", false - } - parts := strings.Split(rest, "/") - for i, p := range parts { - u, err := url.PathUnescape(p) - if err != nil { - return "", false - } - parts[i] = u - } - return strings.Join(parts, "/"), true -} - -// whenOf is a filter's type mask from its words; whenWords the reverse, or false when the mask holds a bit no -// word says (a news filter's). -func whenOf(words []string) int { - n := 0 - for _, w := range words { - n |= filterWhen[w] - } - return n -} - -func whenWords(mask int) ([]string, bool) { - var out []string - left := mask - for _, w := range []string{"new-mail", "manual", "after-junk", "sending", "archiving", "periodic"} { - if mask&filterWhen[w] != 0 { - out = append(out, w) - left &^= filterWhen[w] - } - } - return out, left == 0 -} - -// renderFilters is the file Betterbird reads for a declared list of filters on this node. -func renderFilters(filters []Filter, base string) string { - var b strings.Builder - b.WriteString("version=\"9\"\nlogging=\"no\"\n") - for _, f := range filters { - fmt.Fprintf(&b, "name=\"%s\"\n", f.Name) - fmt.Fprintf(&b, "enabled=\"%s\"\n", map[bool]string{true: "yes", false: "no"}[f.Enabled]) - fmt.Fprintf(&b, "type=\"%d\"\n", whenOf(f.When)) - for _, a := range f.Actions { - fmt.Fprintf(&b, "action=\"%s\"\n", a.Action) - switch { - case a.Folder != "": - fmt.Fprintf(&b, "actionValue=\"%s\"\n", folderAddress(base, a.Folder)) - case a.Value != "": - fmt.Fprintf(&b, "actionValue=\"%s\"\n", a.Value) - } - } - fmt.Fprintf(&b, "condition=\"%s\"\n", conditionText(f)) - } - return b.String() -} - -func conditionText(f Filter) string { - if f.Match == "every-message" { - return "ALL" - } - join := "AND" - if f.Match == "any" { - join = "OR" - } - var parts []string - for _, c := range f.Conditions { - parts = append(parts, fmt.Sprintf("%s (%s,%s,%s)", join, c.Field, c.Op, c.Value)) - } - return strings.Join(parts, " ") -} - -var ( - filterLine = regexp.MustCompile(`^([A-Za-z]+)="((?:[^"\\]|\\.)*)"$`) - clause = regexp.MustCompile(`(AND|OR) \(([^,()]+),([^,()]+),([^()]*)\)`) -) - -// parsedFilters is a filter file read into the setting's shape. NotDeclarable names, by the filter's name, the -// filters a setting cannot hold as they are (a folder of another account, an action of an add-on, a news filter). -type parsedFilters struct { - Filters []Filter - NotDeclarable []string -} - -// parseFilters reads a filter file for an account whose folders are under base. -func parseFilters(text, base string) parsedFilters { - var out parsedFilters - var cur *Filter - bad := "" - finish := func() { - if cur == nil { - return - } - if bad != "" { - out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+bad) - } else if p := filterProblems(*cur); len(p) > 0 { - out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+p[0]) - } else { - out.Filters = append(out.Filters, *cur) - } - cur, bad = nil, "" - } - s := bufio.NewScanner(strings.NewReader(text)) - s.Buffer(make([]byte, 64<<10), 4<<20) - for s.Scan() { - mm := filterLine.FindStringSubmatch(strings.TrimSpace(s.Text())) - if mm == nil { - continue - } - field, value := mm[1], strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(mm[2]) - if field == "name" { - finish() - cur = &Filter{Name: value} - continue - } - if cur == nil { - continue - } - switch field { - case "enabled": - cur.Enabled = value == "yes" - case "type": - n, _ := strconv.Atoi(value) - words, ok := whenWords(n) - if !ok && bad == "" { - bad = "it runs on news or at a time no setting word says" - } - cur.When = words - case "action": - cur.Actions = append(cur.Actions, Action{Action: value}) - case "actionValue": - if len(cur.Actions) == 0 { - continue - } - a := &cur.Actions[len(cur.Actions)-1] - if filterActions[a.Action] == "folder" { - path, ok := folderPath(base, value) - if !ok && bad == "" { - bad = "it moves or copies to a folder of another account" - } - a.Folder = path - } else { - a.Value = value - } - case "condition": - switch { - case value == "ALL": - cur.Match = "every-message" - default: - all := clause.FindAllStringSubmatch(value, -1) - if len(all) == 0 && bad == "" { - bad = "its condition cannot be read" - } - for _, c := range all { - if c[1] == "OR" { - cur.Match = "any" - } else if cur.Match == "" { - cur.Match = "all" - } - cur.Conditions = append(cur.Conditions, Condition{Field: c[2], Op: c[3], Value: c[4]}) - } - } - } - } - finish() - return out -} - -// filterDifferences says how a file's filters differ from the declared list, by place and name: missing, -// not declared, or differing. -func filterDifferences(declared, have []Filter) []string { - var out []string - for i := 0; i < len(declared) || i < len(have); i++ { - switch { - case i >= len(have): - out = append(out, fmt.Sprintf("filter %d (%s) is declared and not in the file", i+1, declared[i].Name)) - case i >= len(declared): - out = append(out, fmt.Sprintf("filter %d (%s) is in the file and not declared: removed at the next apply", i+1, have[i].Name)) - default: - d, h := mustJSON(declared[i]), mustJSON(have[i]) - if d != h { - out = append(out, fmt.Sprintf("filter %d: declared %s, the file holds %s", i+1, d, h)) - } - } - } - return out -} - -// keptFilterFile is where the module keeps the file it replaced at its first write of an account's filters. -func (m *Machine) keptFilterFile(profileDir, account string) string { - base := filepath.Base(profileDir) - return m.home(".config/betterbird/kept", base, account+"."+filterFileName) -} - -func sortedAccountsOf(fs map[string][]Filter) []string { - out := make([]string, 0, len(fs)) - for k := range fs { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/modules/betterbird/cmd/betterbird-tools/install.go b/modules/betterbird/cmd/betterbird-tools/install.go deleted file mode 100644 index e9826e07..00000000 --- a/modules/betterbird/cmd/betterbird-tools/install.go +++ /dev/null @@ -1,307 +0,0 @@ -package main - -// What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release -// in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same -// profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and -// installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the -// system's spelling dictionaries. The tools read these files and say what they find: the version against the -// pin and the newest published build, whether the policy is placed, Owl's version against its pin. -// -// **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the -// node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh -// placed it) and the version in the placed xpi are the pins. The community package's own copy is said as -// such, and the switch from it waits for the operator (ADR 0316 §4). - -import ( - "archive/zip" - "encoding/json" - "fmt" - "io" - "net/http" - "path/filepath" - "strings" - "time" -) - -const ( - installDir = "/opt/betterbird" - // policyFile is the enterprise policy the module places (ADR 0316 §7). - policyFile = installDir + "/distribution/policies.json" - // owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from. - owlFile = installDir + "/owl/owl.xpi" - owlID = "owl@beonex.com" - // defaultClientPref is Betterbird's ask at its start to be the default mail, news, feed and calendar - // client (Thunderbird's all-thunderbird.js: pref("mail.shell.checkDefaultClient", true); messenger.js opens - // systemIntegrationDialog.xhtml while it is true). The module's policy locks it off (novox/hq issue 345). - defaultClientPref = "mail.shell.checkDefaultClient" - // preparation is the tools' program as the module places it for its start (betterbird-tools prepare). - preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools" - // versionsURL is where Betterbird publishes its newest build per major version: what its own updater - // reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with. - versionsURL = "https://www.betterbird.eu/start/versions.txt" -) - -// publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers. -var publishedBuilds = func() (string, error) { - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(versionsURL) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - return string(body), err -} - -// Installation is what is installed, and from where. -type Installation struct { - // From is the mesh, the community package (betterbird-bin, before the switch), or nothing. - From string `json:"from"` - Version string `json:"version,omitempty"` - Build string `json:"build,omitempty"` - // Newest is the newest build Betterbird published for this major version, and whether this one is behind. - Newest string `json:"newest_published_build,omitempty"` - Behind bool `json:"behind,omitempty"` - // Policy says whether the module's enterprise policy is placed, and what it does. - Policy string `json:"policy"` - // Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it. - Owl *OwlState `json:"owl,omitempty"` -} - -// OwlState is Owl against its pin. -type OwlState struct { - Pinned string `json:"pinned,omitempty"` - InProfile string `json:"in_profile,omitempty"` - Active bool `json:"active"` - AtThePin bool `json:"at_the_pin"` - // Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer - // version, and Owl installed by policy cannot be removed in Betterbird. - Note string `json:"note,omitempty"` -} - -// newerVersion says whether version a is newer than b, comparing dotted numbers. -func newerVersion(a, b string) bool { - if a == "" || b == "" { - return false - } - pa, pb := strings.Split(a, "."), strings.Split(b, ".") - for i := 0; i < len(pa) || i < len(pb); i++ { - var x, y int - if i < len(pa) { - fmt.Sscanf(pa[i], "%d", &x) - } - if i < len(pb) { - fmt.Sscanf(pb[i], "%d", &y) - } - if x != y { - return x > y - } - } - return false -} - -// installation reads what is installed. profile may be empty when none is found yet. -func (m *Machine) installation(profile string) Installation { - var in Installation - community, _ := m.installed(packageFor) - raw, err := readBounded(m.path(appIni)) - switch { - case community != "": - in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)" - case err == nil: - in.From = "the mesh" - default: - in.From = "not installed" - } - if err == nil { - _, sec := iniSections(raw) - in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"] - in.Newest, in.Behind = newestBuild(in.Version, in.Build) - } - in.Policy = m.policyState() - pinned := xpiVersion(m.path(owlFile)) - owl := &OwlState{Pinned: pinned} - if profile != "" { - for _, a := range addOns(profile) { - if a.ID == owlID { - owl.InProfile, owl.Active = a.Version, a.Active - } - } - } - owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile - if newerVersion(owl.InProfile, owl.Pinned) { - owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " + - "keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " + - "move the module's pin in a reviewed change" - } - if owl.Pinned != "" || owl.InProfile != "" { - in.Owl = owl - } - return in -} - -// newestBuild is the newest build Betterbird published for a version's major, and whether build is older. -func newestBuild(version, build string) (string, bool) { - major, _, _ := strings.Cut(version, ".") - if major == "" || build == "" { - return "", false - } - list, err := publishedBuilds() - if err != nil { - return "unknown: " + err.Error(), false - } - for _, l := range strings.Split(list, "\n") { - f := strings.Split(strings.TrimSpace(l), ",") - if len(f) == 3 && f[0] == "Linux" && f[1] == major { - return f[2], f[2] > build - } - } - return "unknown: " + versionsURL + " names no build for " + major, false -} - -// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 and issue 345 ask. -func (m *Machine) policyState() string { - raw, err := readBounded(m.path(policyFile)) - if err != nil { - return "not placed: " + policyFile + " is missing" - } - if why := policyProblem(raw); why != "" { - return "placed, and " + why - } - return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " + - "the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " + - "and the start page locked off" -} - -// lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is. -var lockedOff = []struct{ Name, What string }{ - {defaultClientPref, "the ask to be the default mail client"}, - {"mail.inappnotifications.enabled", "the vendor's in-app notices"}, - {"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"}, -} - -// policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "". -func policyProblem(raw []byte) string { - var p struct { - Policies struct { - DisableAppUpdate bool `json:"DisableAppUpdate"` - DisableTelemetry bool `json:"DisableTelemetry"` - ExtensionUpdate *bool `json:"ExtensionUpdate"` - ExtensionSettings map[string]struct { - Mode string `json:"installation_mode"` - URL string `json:"install_url"` - Disabled bool `json:"updates_disabled"` - } `json:"ExtensionSettings"` - } `json:"policies"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return "it is not JSON: " + err.Error() - } - var problems []string - if !p.Policies.DisableAppUpdate { - problems = append(problems, "it does not turn Betterbird's updater off (DisableAppUpdate)") - } - if !p.Policies.DisableTelemetry { - problems = append(problems, "it does not turn telemetry off (DisableTelemetry)") - } - if p.Policies.ExtensionUpdate == nil || *p.Policies.ExtensionUpdate { - problems = append(problems, "it leaves add-ons updating themselves (ExtensionUpdate)") - } - owl, ok := p.Policies.ExtensionSettings[owlID] - switch { - case !ok || owl.Mode != "force_installed": - problems = append(problems, "it does not install Owl into every profile (force_installed)") - case owl.URL != "file://"+owlFile: - problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi") - case !owl.Disabled: - problems = append(problems, "it leaves Owl's updates on") - } - prefs := policyPrefsOf(raw) - for _, l := range lockedOff { - if pr, ok := prefs[l.Name]; !ok || pr.Value != "false" || !pr.Locked { - problems = append(problems, "it does not lock "+l.What+" off (Preferences "+l.Name+": false, locked)") - } - } - return strings.Join(problems, "; ") -} - -// defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does -// not: the placed policy's own Preferences entry locks the ask off, else the profile's user.js (which wins at -// the start) or prefs.js turns it off. Betterbird's own default is to ask. Decided from the entry itself, never -// from what else the policy lacks. -func (m *Machine) defaultClientAsk(profile string) string { - if v, ok := m.locks()[defaultClientPref]; ok && v == "false" { - return "" - } - if profile != "" { - for _, f := range []string{"user.js", "prefs.js"} { - b, err := readBounded(filepath.Join(profile, f)) - if err != nil { - continue - } - if v, ok := statementsOf(b)[defaultClientPref]; ok { - if v == "false" { - return "" - } - return "the profile's " + f + " sets " + defaultClientPref + " to " + v + " and no policy locks it off" - } - } - } - return "no policy locks " + defaultClientPref + " off and Betterbird's default is to ask" -} - -// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read. -func xpiVersion(path string) string { - r, err := zip.OpenReader(path) - if err != nil { - return "" - } - defer r.Close() - for _, f := range r.File { - if f.Name != "manifest.json" { - continue - } - rc, err := f.Open() - if err != nil { - return "" - } - defer rc.Close() - var mf struct { - Version string `json:"version"` - } - if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil { - return "" - } - return mf.Version - } - return "" -} - -// addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from. -func addOns(profile string) []AddOn { - raw, err := readBounded(filepath.Join(profile, "extensions.json")) - if err != nil { - return nil - } - var ext struct { - Addons []struct { - ID string `json:"id"` - Version string `json:"version"` - Active bool `json:"active"` - Location string `json:"location"` - } `json:"addons"` - } - if json.Unmarshal(raw, &ext) != nil { - return nil - } - var out []AddOn - for _, a := range ext.Addons { - if a.Location == "app-profile" { - out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active}) - } - } - return out -} diff --git a/modules/betterbird/cmd/betterbird-tools/install_test.go b/modules/betterbird/cmd/betterbird-tools/install_test.go deleted file mode 100644 index ffbb2884..00000000 --- a/modules/betterbird/cmd/betterbird-tools/install_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package main - -import ( - "archive/zip" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// What the mesh installs, as the tools say it (novox/hq ADR 0316 §16): the version against the pin and the -// newest published build, the policy, Owl against its pin; and the reminder test, visibly a test. - -func (f *fake) xpi(path, version string) { - f.t.Helper() - full := filepath.Join(f.Root, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - f.t.Fatal(err) - } - out, err := os.Create(full) - if err != nil { - f.t.Fatal(err) - } - w := zip.NewWriter(out) - m, _ := w.Create("manifest.json") - m.Write([]byte(`{"version": "` + version + `", "browser_specific_settings": {"gecko": {"id": "owl@beonex.com"}}}`)) - w.Close() - out.Close() -} - -// placedByTheMesh is a node after the switch: the release, the policy and Owl's xpi in /opt/betterbird, and -// the profile running Owl at the pin. -func (f *fake) placedByTheMesh() { - f.noPackage() - f.write(appIni, "[App]\nVendor=Betterbird Project\nVersion=153.4.0\nBuildID=20260929222610\n") - raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json")) - if err != nil { - f.t.Fatal(err) - } - f.write(policyFile, string(raw)) - f.xpi(owlFile, "1.5.6.1") - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.6.1","active":true,"location":"app-profile"}]}`) -} - -func TestTheStatusSaysTheInstallationAgainstThePinTheNewestBuildThePolicyAndOwl(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - links(t, nil) - publishedBuilds = func() (string, error) { - return "blocklist,{}\nLinux,153,20261101000000\nLinux,140,20260829111240\n", nil - } - t.Cleanup(func() { publishedBuilds = func() (string, error) { return "", os.ErrDeadlineExceeded } }) - s, err := f.Status() - if err != nil { - t.Fatal(err) - } - in := s.Installation - if in.From != "the mesh" || in.Version != "153.4.0" || in.Newest != "20261101000000" || !in.Behind || - !strings.HasPrefix(in.Policy, "placed:") || in.Owl == nil || in.Owl.Pinned != "1.5.6.1" || !in.Owl.AtThePin || !in.Owl.Active { - t.Fatalf("%+v %+v", in, in.Owl) - } - c, err := f.Check() - if err != nil { - t.Fatal(err) - } - found := false - for _, x := range c.Findings { - found = found || strings.Contains(x.What, "behind the newest published build 20261101000000") - } - if !found { - t.Fatalf("a release left behind is said: %+v", c.Findings) - } -} - -func TestTheCommunityPackageAPolicyThatIsNotTheModulesAndAnOwlOffItsPinAreSaid(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - links(t, nil) - f.write(policyFile, `{"policies": {"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", - "install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`) - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.7","active":true,"location":"app-profile"}]}`) - in := f.installation(filepath.Join(f.Root, profileDir)) - if !strings.Contains(in.Policy, "DisableAppUpdate") || in.Owl.AtThePin || in.Owl.InProfile != "1.5.7" { - t.Fatalf("%+v %+v", in, in.Owl) - } - // A newer Owl than the pin is a fact with the real remedy, never a finding to remove it. - if !strings.Contains(in.Owl.Note, "move the module's pin") { - t.Fatalf("%+v", in.Owl) - } - f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", - "install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`) - c, _ := f.Check() - for _, x := range c.Findings { - if strings.Contains(x.What, "Owl") { - t.Errorf("a newer Owl is not a finding: %+v", x) - } - } - f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.5","active":true,"location":"app-profile"}]}`) - c, _ = f.Check() - found := false - for _, x := range c.Findings { - found = found || (strings.Contains(x.What, "older than the mesh's pin") && strings.Contains(x.Do, "move the module's Owl pin")) - } - if !found { - t.Fatalf("an older Owl is a finding whose remedy is the pin: %+v", c.Findings) - } - f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", - "install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`) - if in := f.installation(""); !strings.Contains(in.Policy, "not from the mesh's xpi") { - t.Fatalf("%s", in.Policy) - } - f.answer = func(name string, args []string) Output { - if name == "pacman" && args[0] == "-Q" { - return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} - } - return Output{} - } - in = f.installation("") - if !strings.HasPrefix(in.From, "the community package betterbird-bin 153.1.0esr-1") || !strings.Contains(in.From, "operator") { - t.Fatalf("%s", in.From) - } - raw, _ := json.Marshal(in) - if strings.Contains(string(raw), "example.org") { - t.Fatalf("%s", raw) - } -} - -func TestTheReminderTestIsVisiblyATestAndSaysItOpensAWindow(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.declare("// none") - f.declareAccounts(personal) - a, err := f.ReminderTest(40*time.Second, nil, false) - if err != nil { - t.Fatal(err) - } - dir := filepath.Join("/run/user/1000", testUnit) - css := f.read(dir + "/profile/chrome/userChrome.css") - user := f.read(dir + "/profile/user.js") - cal := f.read(dir + "/test.ics") - if !strings.Contains(css, "MESH TEST") || !strings.Contains(css, `:root:not([windowtype="Calendar:AlarmWindow"])`) { - t.Fatalf("the test's windows but the reminder carry a MESH TEST band: %s", css) - } - if !strings.Contains(user, `user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`) || - strings.Contains(user, "mesh_personal") || strings.Contains(user, "example.org") { - t.Fatalf("the test profile reads its userChrome.css and holds no declared account: %s", user) - } - if !strings.Contains(cal, "SUMMARY:MESH TEST reminder") || !strings.Contains(a.OnScreen, "operator's screen") { - t.Fatalf("the event and the answer say it is a test on the operator's screen: %s %+v", cal, a) - } - if !f.called("systemd-run --user --collect --quiet --unit="+testUnit) || !strings.Contains(strings.Join(f.calls, "\n"), startBin+" --no-remote") { - t.Fatalf("the test starts Betterbird itself, never the module's start: %v", f.calls) - } - for _, tool := range tools() { - if tool.Name == "betterbird_reminder_test" && !strings.HasPrefix(tool.Description, "OPENS WINDOWS ON THE OPERATOR'S SCREEN") { - t.Fatalf("the description says first that a window opens on the operator's screen: %s", tool.Description) - } - } -} - -func TestTheUserJSDescriptionSaysAdoption(t *testing.T) { - for _, tool := range tools() { - if tool.Name == "betterbird_user_js" && (!strings.Contains(tool.Description, "adopted under its own keys") || - strings.Contains(tool.Description, "refuses the apply")) { - t.Fatalf("%s", tool.Description) - } - } -} - -// Betterbird never asks at its start to be the default mail client: the module's policy locks -// mail.shell.checkDefaultClient off, and the check says so when it is on (novox/hq issue 345). -func TestTheDefaultClientAskIsLockedOffAndTheCheckSaysWhenItIsOn(t *testing.T) { - f := newFake(t) - f.betterbird() - f.placedByTheMesh() - links(t, nil) - asks := func() string { - t.Helper() - c, err := f.Check() - if err != nil { - t.Fatal(err) - } - for _, x := range c.Findings { - if strings.Contains(x.What, "asks at its start") { - return x.What + " | " + x.Do - } - } - return "" - } - if got := asks(); got != "" { - t.Fatalf("the module's policy locks the ask off, yet: %s", got) - } - unlocked := `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", - "install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}` - if why := policyProblem([]byte(unlocked)); !strings.Contains(why, defaultClientPref) { - t.Fatalf("a policy that leaves the ask on is said: %q", why) - } - f.write(policyFile, unlocked) - if got := asks(); !strings.Contains(got, "default mail client") || !strings.Contains(got, "push the module") { - t.Fatalf("the ask on is a finding with its remedy: %q", got) - } - f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "default"}}, - "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", "install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`) - if why := policyProblem(mustRead(t, f, policyFile)); !strings.Contains(why, "locked") { - t.Fatalf("a default the profile can override is not a lock: %q", why) - } - f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", true);`+"\n") - if got := asks(); got == "" { - t.Fatal("a profile that turned the ask back on over an unlocked default is a finding") - } - f.write(policyFile, unlocked) - f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", false);`+"\n") - if got := asks(); got != "" { - t.Fatalf("a profile with the ask off does not ask: %s", got) - } -} - -func mustRead(t *testing.T, f *fake, p string) []byte { - t.Helper() - raw, err := os.ReadFile(filepath.Join(f.Root, p)) - if err != nil { - t.Fatal(err) - } - return raw -} diff --git a/modules/betterbird/cmd/betterbird-tools/listing.go b/modules/betterbird/cmd/betterbird-tools/listing.go deleted file mode 100644 index d65bd9a5..00000000 --- a/modules/betterbird/cmd/betterbird-tools/listing.go +++ /dev/null @@ -1,480 +0,0 @@ -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 -} diff --git a/modules/betterbird/cmd/betterbird-tools/main.go b/modules/betterbird/cmd/betterbird-tools/main.go deleted file mode 100644 index 5c00de48..00000000 --- a/modules/betterbird/cmd/betterbird-tools/main.go +++ /dev/null @@ -1,339 +0,0 @@ -// The betterbird module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): Betterbird, the -// mail and calendar client, in the operator's session, served by the node's runtime as the operator -// account. The module holds no seat, so every tool is its own. -// -// The operator's mail, calendar events, contacts and credentials are never read: a tool reads processes, -// profiles.ini, and of the profile its prefs.js, user.js, extensions.json, each account's msgFilterRules.dat and -// of logins.json only the server each kept sign-in is for. The tools show the operator's own configuration, an -// account's, an identity's, a calendar's, an address book's, a filter's and a tag's values, and never a credential -// (novox/hq ADR 0329 §1, §2): every answer passes credentialChecked. -package main - -import ( - "fmt" - "os" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -func main() { - // The module's start runs this before Betterbird (novox/hq ADR 0316 §11, §12): the user.js placed and the - // declared accounts written into the account lists, while no Betterbird of this account runs. - if len(os.Args) == 2 && os.Args[1] == "prepare" { - if err := machine.Prepare(); err != nil { - fmt.Fprintln(os.Stderr, "betterbird: "+err.Error()) - os.Exit(1) - } - return - } - if err := stdio.Serve("", tools()); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -var machine = NewMachine() - -func tools() []stdio.Tool { - list := toolList() - for i := range list { - run := list[i].Run - list[i].Run = func(args map[string]any) (any, error) { - out, err := run(args) - return credentialChecked(out), err - } - } - return list -} - -// listTool is the listing tool of one of the five kinds (novox/hq ADR 0329 §12). -func listTool(kind, what string) stdio.Tool { - return stdio.Tool{ - Name: "betterbird_" + strings.ReplaceAll(kind, "-", "_"), - Description: "The " + what + " of the profile Betterbird uses beside the ones the module's setting " + kind + - " declares (novox/hq ADR 0329): each of the profile's with its key, its values and the declared entry it is " + - "(none: the person's own), Owl's marked; each declared one with its value and how it resolved on this node " + - "(adopted, created or refused with why, and its key). The operator's own values, never a password, token " + - "or cookie. betterbird_drift says how they differ. (r)", - Run: func(map[string]any) (any, error) { return machine.List(kind) }, - } -} - -func toolList() []stdio.Tool { - return []stdio.Tool{ - { - Name: "betterbird_status", - Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " + - "many child processes); what is installed in /opt/betterbird and from where (the mesh's pinned release, " + - "or the community package before the switch), its version and build beside the newest published build; " + - "whether the enterprise policy is placed; Owl's version against the mesh's pin and whether it is active; " + - "where its output goes; the profile it uses and how that was found; the module's user.js (declared, " + - "placed, in effect, with every declared identity, calendar, address book and tag as it resolved); the " + - "declared accounts by count (in the profile's list, other accounts, not signed in yet); the add-ons in the " + - "profile; and the preferences that decide how its windows and notifications look. (r)", - Run: func(map[string]any) (any, error) { return machine.Status() }, - }, - { - Name: "betterbird_prefs", - Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (the " + - "module's policy, which locks it; user.js, which wins at the start; prefs.js; or the default), and the " + - "default itself with the file that gives it: Betterbird's own, read from omni.ja and the installation's " + - "defaults/pref, or the module's defaults (mesh-defaults.js). A name ending in . or * is a prefix (at most " + - "200 answered). A preference whose name can hold a credential, or that names an account, a server, a " + - "calendar or a person, is answered without its value. (r)", - Input: map[string]any{ - "names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"}, - }, - Run: func(args map[string]any) (any, error) { - names, err := texts(args, "names") - if err != nil { - return nil, err - } - return machine.Prefs(names) - }, - }, - { - Name: "betterbird_config", - Description: "Betterbird's preferences through the module's layers, preference by preference: the value its " + - "policy locks, the setting prefs (the operator's setup, mesh-wide with this node's layer over it, one entry " + - "per preference), Betterbird's own default where the module keeps that, the profile's own saved value and " + - "the value in effect. It flags drift: a declared value the profile's user.js does not hold yet, or a value " + - "the profile saved where the module keeps Betterbird's default. With keep (names), it proposes this node's " + - "layer of the setting prefs holding the profile's values of those preferences beside what the layer holds, " + - "through the controller's settings verb, for the operator's Approve; it never writes user.js. (r; a with keep)", - Input: map[string]any{ - "keep": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "managed preferences whose profile value this machine keeps: proposed as a node layer, e.g. [\"mail.uifontsize\"]"}, - }, - Run: func(args map[string]any) (any, error) { - keep, err := texts(args, "keep") - if err != nil { - return nil, err - } - return machine.Config(keep) - }, - }, - { - Name: "betterbird_config_apply", - Description: "Bring the profile back to the module's configuration: takes the drifting values " + - "(betterbird_config) out of the profile's prefs.js, so the module's defaults hold at Betterbird's next " + - "start, and says what it cleared, each with the value it had and the one that now holds. Without apply " + - "it lists them only. With apply it acts only while Betterbird is not running, because Betterbird " + - "rewrites prefs.js when it quits; every other line of prefs.js is kept. (r; a with apply)", - Input: map[string]any{ - "apply": map[string]any{"type": "boolean", "description": "clear the drifting values (default false: a dry run)"}, - }, - Run: func(args map[string]any) (any, error) { - apply, err := flag(args, "apply", false) - if err != nil { - return nil, err - } - return machine.ConfigApply(apply) - }, - }, - { - Name: "betterbird_user_js", - Description: "The module's user.js — the setting prefs, then the declared accounts' preferences (setting " + - "accounts), then the declared identities, calendars, address books and tags — against the profile's " + - "user.js: whether it is placed, and whether the running Betterbird started after it was. With apply, place " + - "it in the profile (only where the profile has no user.js or the module's own; an operator's file is never " + - "overwritten). A declared entry the profile already holds is adopted under its own keys; one it does not " + - "hold is created under the module's keys; an ambiguous one is refused alone, with the remedy (novox/hq ADR " + - "0317, ADR 0329 §7). What Betterbird rewrites itself — the account and identity lists, the calendars' " + - "order, the removals and each declared account's msgFilterRules.dat — is written only while Betterbird is " + - "closed, and the answer counts what waits (§8, §9). Accounts' values are withheld in the user.js shown. " + - "Betterbird reads it at its next start: betterbird_restart. (r; a with apply)", - Input: map[string]any{ - "apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"}, - }, - Run: func(args map[string]any) (any, error) { - apply, err := flag(args, "apply", false) - if err != nil { - return nil, err - } - return machine.userJS(apply) - }, - }, - { - Name: "betterbird_log", - Description: "The last lines Betterbird printed, from the journal of the unit the module starts it as " + - "(mesh-betterbird; or mesh-betterbird-reminder-test). With problems, only lines that say error, " + - "warning, assertion, crash or failure. Mail addresses and credentials are replaced by . A " + - "Betterbird the module did not start prints elsewhere, and the answer says so. (r)", - Input: map[string]any{ - "lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"}, - "problems": map[string]any{"type": "boolean", "description": "only problem lines (default false)"}, - "unit": map[string]any{"type": "string", "enum": []string{restartAs, testUnit}, "description": "which start's journal (default " + restartAs + ")"}, - }, - Run: func(args map[string]any) (any, error) { - n, err := whole(args, "lines", 100, 1, 2000) - if err != nil { - return nil, err - } - problems, err := flag(args, "problems", false) - if err != nil { - return nil, err - } - unit, err := text(args, "unit", false) - if err != nil { - return nil, err - } - return machine.Log(n, problems, unit) - }, - }, - { - Name: "betterbird_restart", - Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " + - "allows it, and start Betterbird again in the operator's desktop session under the account's service " + - "manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " + - "(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " + - "ask the operator first. Answers the pids ended and the new main process. (a)", - Input: map[string]any{ - "discard_unsent": map[string]any{"type": "boolean", "description": "restart even with a composer open, losing what it holds (default false)"}, - }, - Run: func(args map[string]any) (any, error) { - discard, err := flag(args, "discard_unsent", false) - if err != nil { - return nil, err - } - return machine.Restart(discard) - }, - }, - { - Name: "betterbird_check", - Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " + - "behind the newest published build; the enterprise policy placed and the module's defaults with it; no " + - "managed preference drifting from them; Owl at its pin and active; one " + - "Betterbird runs; its profile is found; the module's user.js on the machine, valid, placed and in " + - "effect; the declared accounts in the profile's list, none refused, each signed in once; per kind, no " + - "declared identity, calendar, address book, filter or tag drifting or refused, and no change waiting for " + - "Betterbird to close; its output read. Answers ok and each finding with what to do. (r)", - Run: func(map[string]any) (any, error) { return machine.Check() }, - }, - { - Name: "betterbird_accounts", - Description: "The accounts of the profile Betterbird uses with their values (name, address, user, servers' " + - "hosts and ports) and the declared account each is, Owl's marked by name and address (never declared), " + - "with counts by kind. Never a password, a token or anything of the login store (novox/hq ADR 0329 §1, §2). " + - "With propose, it builds the module's setting accounts from them (every account but Owl's) and proposes " + - "it in the mesh-wide layer as it stands, through the controller's settings verb, for the operator's " + - "Approve on a channel that proves who answers (novox/hq ADR 0317, ADR 0277); the answer shows the value it " + - "proposed. (r; a with propose)", - Input: map[string]any{ - "propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"}, - }, - Run: func(args map[string]any) (any, error) { - propose, err := flag(args, "propose", false) - if err != nil { - return nil, err - } - return machine.ProfileAccounts(propose) - }, - }, - listTool(kindIdentities, "identities (each account's default and its other identities, with name, address, reply-to, organisation and signature)"), - listTool(kindCalendars, "calendars (CalDAV with name, address, colour and options; an ICS one by its name only)"), - listTool(kindAddressBooks, "address books (CardDAV with name, address and user; a local one by its name)"), - listTool(kindFilters, "filters (per declared account: its msgFilterRules.dat as filters, folders by their path within the account; where the module keeps a file it replaced)"), - listTool(kindTags, "tags (key, name, colour, place)"), - { - Name: "betterbird_derive", - Description: "Derive the module's settings identities, calendars, address-books, filters and tags (or the kinds " + - "given) from the profiles of every desktop node holding Betterbird, laid out per layer: an entry on every " + - "such node goes into the mesh-wide layer with this node's value, and the answer lists each field another " + - "node holds differently; one on some nodes only goes into those nodes' own layers. Owl's, ICS calendars, " + - "local address books and any address carrying a password are left out and named. prefs puts values of the " + - "setting prefs into the mesh-wide layer. With propose, the next layer (the mesh-wide first, then each " + - "node's) is proposed for the operator's Approve through the controller's settings verb, carrying the layer " + - "as it stands beside the derived kinds; refused while a proposal of the module's waits, or while a desktop " + - "node does not answer (novox/hq ADR 0329 §10, ADR 0277). here answers this node's profile only, as the " + - "other nodes are asked. The values are the operator's own; never a credential. (r; a with propose)", - Input: map[string]any{ - "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": allKinds}, - "description": "the kinds to derive (default all five)"}, - "here": map[string]any{"type": "boolean", "description": "this node's profile only (default false)"}, - "propose": map[string]any{"type": "boolean", "description": "propose the next layer to the operator (default false)"}, - "prefs": map[string]any{"type": "object", "description": "values of the setting prefs for the mesh-wide " + - "layer, by preference name, e.g. {\"mail.uifontsize\": 14}"}, - "layer": map[string]any{"type": "string", "description": "the layer to propose instead of the next: mesh-wide or a node's name"}, - }, - Run: func(args map[string]any) (any, error) { - kinds, err := texts(args, "kinds") - if err != nil { - return nil, err - } - here, err := flag(args, "here", false) - if err != nil { - return nil, err - } - propose, err := flag(args, "propose", false) - if err != nil { - return nil, err - } - layer, err := text(args, "layer", false) - if err != nil { - return nil, err - } - var prefs map[string]any - if v, ok := args["prefs"]; ok && v != nil { - if prefs, ok = v.(map[string]any); !ok { - return nil, fmt.Errorf("prefs is an object of preference names and values, not %T", v) - } - } - return machine.Derive(kinds, here, propose, prefs, layer) - }, - }, - { - Name: "betterbird_drift", - Description: "How this node's profile differs from the declared identities, calendars, address books, " + - "filters and tags (novox/hq ADR 0329 §11): per declared entry whether the profile holds it, holds another " + - "value (each differing field with the declared value and the profile's), lacks it, or it is refused; and " + - "the profile's entries nothing declares, which stay the person's. The profile as Betterbird reads it at " + - "its next start. (r)", - Input: map[string]any{ - "kind": map[string]any{"type": "string", "enum": allKinds, "description": "one kind (default every kind)"}, - }, - Run: func(args map[string]any) (any, error) { - kind, err := text(args, "kind", false) - if err != nil { - return nil, err - } - return machine.Drift(kind) - }, - }, - { - Name: "betterbird_reminder_test", - Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " + - "reminder without touching the operator's calendar: a second Betterbird in a throw-away profile under " + - "the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most 10 minutes), its main " + - "window marked MESH TEST in red, with the module's user.js (no accounts) and one local calendar file " + - "holding one event, \"MESH TEST reminder\", whose reminder is due after `after` seconds. carry copies " + - "named preferences of the operator's profile (a theme, a colour switch) into it, never a credential or " + - "an account. Then xorg_windows (class Calendar) and xorg_screenshot show the reminder window. stop ends " + - "it and removes the profile. (a)", - Input: map[string]any{ - "after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"}, - "carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "preference names to copy from the operator's profile, e.g. [\"extensions.activeThemeID\"]"}, - "stop": map[string]any{"type": "boolean", "description": "end the test Betterbird and remove its profile (default false)"}, - }, - Run: func(args map[string]any) (any, error) { - after, err := whole(args, "after", 40, 15, 300) - if err != nil { - return nil, err - } - carry, err := texts(args, "carry") - if err != nil { - return nil, err - } - stop, err := flag(args, "stop", false) - if err != nil { - return nil, err - } - return machine.ReminderTest(time.Duration(after)*time.Second, carry, stop) - }, - }, - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/manifest_test.go b/modules/betterbird/cmd/betterbird-tools/manifest_test.go deleted file mode 100644 index 37e420b5..00000000 --- a/modules/betterbird/cmd/betterbird-tools/manifest_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "regexp" - "strconv" - "strings" - "testing" -) - -// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory -// in the home with the mergeable files the settings fill, each naming the keys it takes (ADR 0326): prefs, accounts -// and configuration (identities, calendars, address-books, filters) trusted, tags not (ADR 0329 §5); the community package declared absent before anything is placed in /opt/betterbird; Betterbird from -// its pinned upstream release, Owl pinned by its vendor's digest, the policy and the dictionaries' default -// unpacked into the installation; the tools' program for the start's preparation, the start and the launcher; -// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools. - -type manifest struct { - Module string `json:"module"` - Version string `json:"version"` - Upgrade map[string]string `json:"upgrade"` - Requires []string `json:"requires"` - Invokes []string `json:"invokes"` - Config map[string]any `json:"config"` - Tools []string `json:"tools"` - Resources []map[string]any `json:"resources"` - Build struct { - Artifacts []map[string]any `json:"artifacts"` - } `json:"build"` -} - -func readManifest(t *testing.T) (manifest, string) { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) - if err != nil { - t.Fatal(err) - } - dec := json.NewDecoder(strings.NewReader(string(raw))) - dec.DisallowUnknownFields() - var m manifest - if err := dec.Decode(&m); err != nil { - t.Fatalf("module.json: %v", err) - } - return m, string(raw) -} - -func resource(t *testing.T, m manifest, id string) map[string]any { - t.Helper() - for _, r := range m.Resources { - if r["id"] == id { - return r - } - } - t.Fatalf("module.json has no resource %s", id) - return nil -} - -func artifact(t *testing.T, m manifest, name string) map[string]any { - t.Helper() - for _, a := range m.Build.Artifacts { - if a["name"] == name { - return a - } - } - t.Fatalf("module.json builds no artifact %s", name) - return nil -} - -func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *testing.T) { - m, raw := readManifest(t) - if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { - t.Fatalf("%+v", m) - } - // Every new build waits for a person's push: the switch removes a package and replaces what a running - // Betterbird has open (ADR 0316 §4). - if m.Upgrade["policy"] != "record" || strings.TrimSpace(m.Upgrade["why"]) == "" { - t.Fatalf("the module is marked for a person's approval, with why: %v", m.Upgrade) - } - var ids []string - for _, r := range m.Resources { - ids = append(ids, r["id"].(string)) - } - want := "configuration-dir,prefs,accounts,configuration,tags,community-package,installation,owl,policies,spelling,preparation,start,launcher" - if strings.Join(ids, ",") != want { - t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want) - } - pkg := resource(t, m, "community-package") - if pkg["type"] != "package" || pkg["package"] != packageFor || pkg["absent"] != true { - t.Fatalf("the community package is declared absent (ADR 0180): %v", pkg) - } - places := map[string]string{"installation": installDir, "owl": filepath.Dir(owlFile), - "policies": filepath.Dir(policyFile), "spelling": installDir + "/defaults/pref", "preparation": filepath.Dir(preparation)} - arts := map[string]string{"installation": "archive", "owl": "owl", "policies": "distribution", "spelling": "defaults", "preparation": "tools"} - for id, path := range places { - r := resource(t, m, id) - if r["type"] != "archive" || r["path"] != path || r["artifact"] != arts[id] || r["owner"] != nil { - t.Errorf("%s is the %s archive unpacked at %s, root's: %v", id, arts[id], path, r) - } - } - for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} { - if regexp.MustCompile(regexp.QuoteMeta(never) + `\b`).MatchString(raw) { - t.Errorf("module.json names %q: the profile is Betterbird's", never) - } - } - s := strings.ToLower(raw) - for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} { - if strings.Contains(s, never) { - t.Errorf("module.json names %q", never) - } - } -} - -// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the -// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's -// decision); each is an object whose default is the file's own, empty, never required, since a node without the -// operator's setup still runs Betterbird; and each file's default declares nothing. -func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) { - m, _ := readManifest(t) - if _, said := m.Config["tags"]; !said || len(m.Config) != 7 { - t.Fatalf("the seven values are declared under config: %v", m.Config) - } - for key, raw := range m.Config { - d, _ := raw.(map[string]any) - def, _ := d["default"].(map[string]any) - if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 { - t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d) - } - } - files := map[string]struct { - path, mode string - takes []any - trusted bool - }{ - "prefs": {prefsFile, "0644", []any{"prefs"}, true}, - "accounts": {accountsFile, "0600", []any{"accounts"}, true}, - "configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true}, - "tags": {tagsFile, "0644", []any{"tags"}, true}, - } - merging := 0 - for _, r := range m.Resources { - if r["merge"] != nil { - merging++ - } - } - if merging != len(files) { - t.Fatalf("%d mergeable files, want %d", merging, len(files)) - } - for id, want := range files { - r := resource(t, m, id) - if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted || - r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) { - t.Errorf("%s: %v", id, r) - } - var content map[string]any - if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) { - t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err) - } - } - got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts")))) - if err != nil || len(got) != 0 { - t.Fatalf("its default declares no account: %v %v", got, err) - } - prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs")))) - if err != nil || len(prefs) != 0 { - t.Fatalf("its default declares no preference: %v %v", prefs, err) - } - var c Configuration - if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil { - t.Fatal(err) - } - if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil { - t.Fatal(err) - } -} - -func TestBothPinsAreAURLAVersionAndASha256AndOwlIsNeverInTheRepository(t *testing.T) { - m, _ := readManifest(t) - sha := regexp.MustCompile(`^[0-9a-f]{64}$`) - for _, name := range []string{"archive", "owl"} { - a := artifact(t, m, name) - u, _ := a["url"].(string) - if a["kind"] != "archive" || !strings.HasPrefix(u, "https://") || !sha.MatchString(fmt.Sprint(a["sha256"])) || - strings.TrimSpace(fmt.Sprint(a["version"])) == "" || a["from"] != nil { - t.Errorf("%s is pinned by URL, version and sha256, and copied from nowhere in the repository: %v", name, a) - } - } - if a := artifact(t, m, "archive"); a["within"] != "betterbird" { - t.Errorf("the release unpacks into betterbird/, whose content is the installation: %v", a) - } - if a := artifact(t, m, "owl"); a["file"] != filepath.Base(owlFile) { - t.Errorf("Owl's xpi is kept whole as %s: %v", filepath.Base(owlFile), a) - } - // Owl's licence forbids copying it to third parties: no xpi anywhere in the module's tree. - filepath.Walk(filepath.Join("..", ".."), func(p string, info os.FileInfo, err error) error { - if err == nil && strings.HasSuffix(strings.ToLower(p), ".xpi") { - t.Errorf("%s is in the repository", p) - } - return nil - }) -} - -// owlSupports is the range of Betterbird versions each pinned Owl declares in its manifest.json -// (browser_specific_settings.gecko strict_min_version, strict_max_version), read from the vendor's xpi -// when its pin was taken. Moving Owl's pin means reading the new xpi's range into here. -var owlSupports = map[string][2]string{ - "1.5.6.1": {"115.0", "156.*"}, -} - -// owlAccepts says why a Betterbird version is outside the range an Owl version declares, or nil. -func owlAccepts(owl, betterbird string) error { - r, known := owlSupports[owl] - if !known { - return fmt.Errorf("Owl %s's supported range is not recorded in owlSupports: read it from the xpi's manifest.json", owl) - } - major := func(v string) int { - head, _, _ := strings.Cut(v, ".") - n, err := strconv.Atoi(head) - if err != nil { - return -1 - } - return n - } - b, lo, hi := major(betterbird), major(r[0]), major(r[1]) - if b < 0 || b < lo || b > hi { - return fmt.Errorf("Betterbird %s is outside the range Owl %s supports (%s to %s): a Betterbird Owl does not support "+ - "waits for Owl's pin to move (novox/hq ADR 0316 §8)", betterbird, owl, r[0], r[1]) - } - return nil -} - -func TestABetterbirdOwlDoesNotSupportIsRefused(t *testing.T) { - m, _ := readManifest(t) - owl, bb := fmt.Sprint(artifact(t, m, "owl")["version"]), fmt.Sprint(artifact(t, m, "archive")["version"]) - if err := owlAccepts(owl, bb); err != nil { - t.Fatal(err) - } - for _, outside := range []string{"157.0esr-bb1", "102.15.1esr-bb4"} { - if err := owlAccepts(owl, outside); err == nil { - t.Errorf("Betterbird %s was accepted with Owl %s", outside, owl) - } - } - if err := owlAccepts("9.9", bb); err == nil { - t.Error("an Owl whose range nobody read was accepted") - } -} - -func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.T) { - m, _ := readManifest(t) - if a := artifact(t, m, "distribution"); a["kind"] != "archive" || a["from"] != "install/distribution" { - t.Fatalf("%v", a) - } - raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json")) - if err != nil { - t.Fatal(err) - } - if why := policyProblem(raw); why != "" { - t.Fatalf("the module's policies.json: %s", why) - } - entries, _ := os.ReadDir(filepath.Join("..", "..", "install", "distribution")) - if len(entries) != 1 { - t.Fatalf("the distribution directory holds the policy alone: %v", entries) - } - if a := artifact(t, m, "defaults"); a["kind"] != "archive" || a["from"] != "install/defaults-pref" { - t.Fatalf("%v", a) - } - prefs, err := os.ReadFile(filepath.Join("..", "..", "install", "defaults-pref", "vendor-prefs.js")) - if err != nil || !strings.Contains(string(prefs), `pref("spellchecker.dictionary_path", "/usr/share/hunspell");`) { - t.Fatalf("the dictionaries are named as a default, in place of a link: %s %v", prefs, err) - } -} - -func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) { - m, _ := readManifest(t) - start := resource(t, m, "start") - content := templateText(t, start) - if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" || - !strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") { - t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start) - } - launcher := resource(t, m, "launcher") - lc := templateText(t, launcher) - if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" || - !strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") || - !strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") { - t.Fatalf("the launcher starts the module's start and names the icon in the installation: %v", launcher) - } - if b := artifact(t, m, "tools"); filepath.Base(preparation) != b["binary"] { - t.Fatalf("the preparation runs the tools' binary: %v", b) - } -} - -func TestTheToolsAgreeWithTheManifest(t *testing.T) { - m, _ := readManifest(t) - served := map[string]bool{} - for _, tool := range tools() { - served[tool.Name] = true - if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" { - t.Errorf("%s: prefixed betterbird_ and described", tool.Name) - } - } - for _, name := range m.Tools { - if !served[name] { - t.Errorf("module.json lists %s, which the bundle does not serve", name) - } - delete(served, name) - } - for name := range served { - t.Errorf("the bundle serves %s, which module.json does not list", name) - } - b := artifact(t, m, "tools") - if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" { - t.Errorf("the Go tools bundle: %v", b) - } - // The tools propose through the controller's settings verb (ADR 0317 §9), and betterbird_derive asks the controller - // which nodes hold the module and each of them for its profile (ADR 0329 §10). - if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings", "seat:mesh-controller.nodes", - "seat:mesh-controller.node", "betterbird.betterbird_derive"}) { - t.Errorf("the module invokes the controller's settings, nodes and node verbs and its own derive: %v", m.Invokes) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/owned.go b/modules/betterbird/cmd/betterbird-tools/owned.go deleted file mode 100644 index bba5df4e..00000000 --- a/modules/betterbird/cmd/betterbird-tools/owned.go +++ /dev/null @@ -1,749 +0,0 @@ -package main - -// How the five settings of ADR 0329 reach the profile (§7–§9). -// -// Identities, calendars, address books and tags are matched as accounts are (ADR 0317): an entry the module -// recorded on this node is kept by its recorded key; else an entry under the module's own key is its creation; -// else a key a node's layer names is adopted when the profile holds it and it matches; else the profile's -// entries that match (an identity by its address within its account, a calendar and an address book by their -// address, a tag by its key): one is adopted, none means the entry is created under the module's own key, more -// than one is refused with the remedy. Their declared fields are written into the user.js the module places, -// where they win at every start. An entry leaving the setting releases what was adopted and removes the -// preferences of what was created; never a calendar's events, an address book's contacts or a cache. -// -// What Betterbird rewrites itself is written only while it does not run (§9): the lists in prefs.js (an adopted -// account's identities, the calendars' order) and each account's msgFilterRules.dat. Until then the module's -// check counts, per kind, the changes that wait. - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" -) - -// configRecordFile is where the module records, per profile, what it adopted or created of the five kinds and -// which filter files it wrote (ADR 0329 §7, §8, ADR 0317 §3). -const configRecordFile = ".config/betterbird/configuration-record.json" - -// resolvedEntry is one declared entry as it resolved on this node. -type resolvedEntry struct { - Kind string `json:"kind"` - Name string `json:"name"` - How string `json:"how"` - Key string `json:"key,omitempty"` - Why string `json:"why,omitempty"` -} - -type recordedEntry struct { - How string `json:"how"` - Key string `json:"key"` -} - -// filterRecord is what the module did with one account's filter file on a profile. -type filterRecord struct { - // File is the account's filter file as the module last wrote it, and Kept the file it replaced at its first write. - File string `json:"file"` - Kept string `json:"kept,omitempty"` - // Released says the account left the setting filters: the module no longer writes its file (§8). - Released bool `json:"released,omitempty"` -} - -type configRecord struct { - Entries map[string]map[string]recordedEntry `json:"entries,omitempty"` - Filters map[string]filterRecord `json:"filters,omitempty"` -} - -func (m *Machine) readConfigRecord(profileDir string) configRecord { - all := map[string]configRecord{} - if raw, err := readBounded(m.home(configRecordFile)); err == nil { - _ = json.Unmarshal(raw, &all) - } - r := all[profileDir] - if r.Entries == nil { - r.Entries = map[string]map[string]recordedEntry{} - } - if r.Filters == nil { - r.Filters = map[string]filterRecord{} - } - return r -} - -func (m *Machine) writeConfigRecord(profileDir string, r configRecord) error { - all := map[string]configRecord{} - if raw, err := readBounded(m.home(configRecordFile)); err == nil { - _ = json.Unmarshal(raw, &all) - } - all[profileDir] = r - body, err := json.MarshalIndent(all, "", " ") - if err != nil { - return err - } - return writeAtomic(m.home(configRecordFile), append(body, '\n'), 0o600) -} - -// writeAtomic writes a file beside itself and renames it into place. -func writeAtomic(path string, data []byte, mode os.FileMode) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - tmp := path + ".mesh-new" - if err := writeSynced(tmp, data, mode); err != nil { - os.Remove(tmp) - return err - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return err - } - return nil -} - -// The module's own keys for what it creates: each names the entry. -func ownIdentityKey(name string) string { return "ident_mesh_" + name } -func ownCalendarKey(name string) string { return "mesh_" + name } -func ownBookKey(name string) string { return "mesh_" + name } - -var ( - ownIdentity = regexp.MustCompile(`^ident_mesh_[a-z][a-z0-9]{0,30}$`) - ownMeshKey = regexp.MustCompile(`^mesh_[a-z][a-z0-9]{0,30}$`) -) - -// configPlan is what an apply does for the five kinds on this profile. -type configPlan struct { - Entries []resolvedEntry - // lines are the user.js statements, a comment before each entry's. - lines []string - // identityLists are, per adopted account key, the identity keys of the module's own it wants in its list. - identityLists map[string][]string - // calendarOrder are the calendar keys the module created, for calendar.list.sortOrder. - calendarOrder []string - // removals are the preference prefixes of what the module created and the setting no longer holds. - removals []string - filters []filterPlan - // released are the accounts that left the setting filters since the module last wrote their file. - released []string - record configRecord - problem string -} - -// filterPlan is one declared account's filter file on this node. -type filterPlan struct { - Account string `json:"account"` - File string `json:"file,omitempty"` - // Waiting says why the file cannot be written yet, when it cannot. - Waiting string `json:"waiting,omitempty"` - Refused string `json:"refused,omitempty"` - want string - have string - exists bool - path string -} - -func (p filterPlan) changes() bool { return p.Refused == "" && p.path != "" && p.want != p.have } - -// planConfiguration resolves every declared entry against the profile and says what an apply writes. -func (m *Machine) planConfiguration(profile Profile, c Configuration, accounts map[string]Account, - ra []resolvedAccount, prefs map[string]string) configPlan { - rec := m.readConfigRecord(profile.Dir) - plan := configPlan{identityLists: map[string][]string{}, - record: configRecord{Entries: map[string]map[string]recordedEntry{}, Filters: map[string]filterRecord{}}} - byName := map[string]resolvedAccount{} - for _, r := range ra { - byName[r.name] = r - } - have := profileAccounts(prefs) - listOfAccount := map[string][]string{} - for _, a := range have { - listOfAccount[a.Key] = a.Identities - } - identityKeyOf := map[string]string{} - - // Identities. - taken := map[string]bool{} - for _, name := range sortedKeys(c.Identities) { - id := c.Identities[name] - e := resolvedEntry{Kind: kindIdentities, Name: name} - acct, declared := byName[id.Account] - switch { - case !declared: - e.How, e.Why = refused, fmt.Sprintf("its account %s is not in the setting accounts", id.Account) - case acct.How == refused: - e.How, e.Why = refused, fmt.Sprintf("its account %s is refused: betterbird_check says why", id.Account) - case id.Address == "": - e.How, e.Key = acct.How, acct.Keys.Identity - if taken[e.Key] { - e.How, e.Key, e.Why = refused, "", fmt.Sprintf("account %s has its default identity declared twice", id.Account) - } - default: - list := listOfAccount[acct.Keys.Account] - var others []string - if len(list) > 1 { - others = list[1:] - } - present := func(k string) bool { - for _, x := range others { - if x == k { - return true - } - } - return false - } - matches := func(k string) bool { - return strings.EqualFold(unquote(prefs["mail.identity."+k+".useremail"]), id.Address) - } - e = resolveEntry(kindIdentities, name, id.Key, ownIdentityKey(name), rec.Entries[kindIdentities][name], - others, present, matches, taken, "an identity of its account with its address", - "give the one to adopt as key in this node's layer of the setting identities") - if e.How == created { - plan.identityLists[acct.Keys.Account] = append(plan.identityLists[acct.Keys.Account], e.Key) - } - } - if e.How != refused { - taken[e.Key] = true - identityKeyOf[name] = e.Key - plan.lines = append(plan.lines, fmt.Sprintf("// identity %s: %s under %s", name, e.How, e.Key)) - plan.lines = append(plan.lines, identityLines(e, id, acct)...) - } - plan.Entries = append(plan.Entries, e) - } - // A created account's identity list is the module's whole: its default and the identities it created. - for _, name := range sortedKeys(byName) { - r := byName[name] - if r.How == created && len(plan.identityLists[r.Keys.Account]) > 0 { - list := append([]string{r.Keys.Identity}, plan.identityLists[r.Keys.Account]...) - plan.lines = append(plan.lines, fmt.Sprintf("user_pref(%s, %s);", quoted("mail.account."+r.Keys.Account+".identities"), - quoted(strings.Join(list, ",")))) - delete(plan.identityLists, r.Keys.Account) - } - } - // Adopted accounts keep their list in prefs.js: the module's own keys are written into it while Betterbird is - // closed. An adopted account whose list holds none of them and wants none is left out. - for _, a := range have { - if _, wanted := plan.identityLists[a.Key]; wanted { - continue - } - for _, k := range a.Identities { - if ownIdentity.MatchString(k) { - plan.identityLists[a.Key] = []string{} - break - } - } - } - - // Calendars. - cals := profileCalendars(prefs) - calKeys := map[string]profileCalendar{} - var caldav []string - for _, pc := range cals { - calKeys[pc.Key] = pc - if pc.Kind == "caldav" { - caldav = append(caldav, pc.Key) - } - } - taken = map[string]bool{} - for _, name := range sortedKeys(c.Calendars) { - cal := c.Calendars[name] - present := func(k string) bool { pc, ok := calKeys[k]; return ok && pc.Kind == "caldav" } - matches := func(k string) bool { return sameURL(calKeys[k].Value.URL, cal.URL) } - e := resolveEntry(kindCalendars, name, cal.Key, ownCalendarKey(name), rec.Entries[kindCalendars][name], - caldav, present, matches, taken, "a CalDAV calendar at its address", - "remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting calendars") - if e.How != refused { - taken[e.Key] = true - if e.How == created { - plan.calendarOrder = append(plan.calendarOrder, e.Key) - } - plan.lines = append(plan.lines, fmt.Sprintf("// calendar %s: %s under %s", name, e.How, e.Key)) - plan.lines = append(plan.lines, calendarLines(e, cal, identityKeyOf[cal.Invitations])...) - } - plan.Entries = append(plan.Entries, e) - } - - // Address books. - books := profileAddressBooks(prefs) - bookKeys := map[string]profileAddressBook{} - var carddav []string - for _, b := range books { - bookKeys[b.Key] = b - if b.CardDAV { - carddav = append(carddav, b.Key) - } - } - taken = map[string]bool{} - for _, name := range sortedKeys(c.AddressBooks) { - ab := c.AddressBooks[name] - present := func(k string) bool { b, ok := bookKeys[k]; return ok && b.CardDAV } - matches := func(k string) bool { return sameURL(bookKeys[k].Value.URL, ab.URL) } - e := resolveEntry(kindAddressBooks, name, ab.Key, ownBookKey(name), rec.Entries[kindAddressBooks][name], - carddav, present, matches, taken, "a CardDAV address book at its address", - "remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting address-books") - if e.How != refused { - taken[e.Key] = true - plan.lines = append(plan.lines, fmt.Sprintf("// address book %s: %s under %s", name, e.How, e.Key)) - plan.lines = append(plan.lines, addressBookLines(e, ab, name)...) - } - plan.Entries = append(plan.Entries, e) - } - - // Tags: a tag's key is its own, unique in a profile, so a match is never ambiguous. - tagKeys := map[string]bool{} - for _, t := range profileTags(prefs) { - tagKeys[t.Key] = true - } - for _, key := range sortedKeys(c.Tags) { - t := c.Tags[key] - e := resolvedEntry{Kind: kindTags, Name: key, Key: key, How: adopted} - if was, ok := rec.Entries[kindTags][key]; ok && was.How == created { - e.How = created - } else if !tagKeys[key] { - e.How = created - } - plan.lines = append(plan.lines, fmt.Sprintf("// tag %s: %s", key, e.How)) - plan.lines = append(plan.lines, tagLines(key, t)...) - plan.Entries = append(plan.Entries, e) - } - - // What the setting no longer holds: an adopted entry is released (its lines go), a created one's preferences - // are removed from prefs.js while Betterbird is closed. - declaredNow := map[string]map[string]bool{} - for _, e := range plan.Entries { - if declaredNow[e.Kind] == nil { - declaredNow[e.Kind] = map[string]bool{} - } - declaredNow[e.Kind][e.Name] = true - if e.How != refused { - if plan.record.Entries[e.Kind] == nil { - plan.record.Entries[e.Kind] = map[string]recordedEntry{} - } - plan.record.Entries[e.Kind][e.Name] = recordedEntry{How: e.How, Key: e.Key} - } - } - for _, kind := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindTags} { - for _, name := range sortedKeys(rec.Entries[kind]) { - was := rec.Entries[kind][name] - if declaredNow[kind][name] || was.How != created { - continue - } - switch kind { - case kindIdentities: - plan.removals = append(plan.removals, "mail.identity."+was.Key+".") - case kindCalendars: - plan.removals = append(plan.removals, "calendar.registry."+was.Key+".") - case kindAddressBooks: - plan.removals = append(plan.removals, "ldap_2.servers."+was.Key+".") - case kindTags: - plan.removals = append(plan.removals, "mailnews.tags."+was.Key+".") - } - } - } - - // Filters, owned whole per declared account (§8). - for _, account := range sortedAccountsOf(c.Filters) { - fp := filterPlan{Account: account} - r, ok := byName[account] - switch { - case !ok: - fp.Refused = fmt.Sprintf("its account %s is not in the setting accounts", account) - case r.How == refused: - fp.Refused = fmt.Sprintf("its account %s is refused: betterbird_check says why", account) - default: - dir := m.accountDirectory(profile.path, prefs, r.Keys.Server) - if dir == "" { - fp.Waiting = "Betterbird has not made the account's directory yet: it does at its first start with the account" - break - } - fp.path = filepath.Join(dir, filterFileName) - fp.File = m.tilde(strings.TrimPrefix(fp.path, m.Root)) - fp.want = renderFilters(c.Filters[account], folderBase(prefs, r.Keys.Server)) - raw, err := readBounded(fp.path) - fp.exists = err == nil - fp.have = string(raw) - } - was := rec.Filters[account] - was.Released = false - if fp.path != "" { - was.File = fp.File - } - if was.File != "" || was.Kept != "" { - plan.record.Filters[account] = was - } - plan.filters = append(plan.filters, fp) - } - for _, account := range sortedKeys(rec.Filters) { - if _, still := c.Filters[account]; still { - continue - } - was := rec.Filters[account] - if !was.Released { - plan.released = append(plan.released, account) - } - was.Released = true - plan.record.Filters[account] = was - } - return plan -} - -// resolveEntry resolves an identity, a calendar or an address book as ADR 0317 resolves an account (§7). -func resolveEntry(kind, name, layerKey, own string, was recordedEntry, candidates []string, present, matches func(string) bool, - taken map[string]bool, what, remedy string) resolvedEntry { - e := resolvedEntry{Kind: kind, Name: name} - refuse := func(format string, args ...any) resolvedEntry { - e.How, e.Key, e.Why = refused, "", fmt.Sprintf(format, args...) - return e - } - // 1. Recorded, while the profile still holds it. - if was.Key != "" && !taken[was.Key] && (present(was.Key) || was.How == created) && (layerKey == "" || layerKey == was.Key) { - e.How, e.Key = was.How, was.Key - return e - } - // 2. Under the module's own key: its creation. - if present(own) { - e.How, e.Key = created, own - return e - } - // 3. The key a node's layer names. - if layerKey != "" { - switch { - case !present(layerKey): - return refuse("the key %s this node's layer names is not in the profile", layerKey) - case taken[layerKey]: - return refuse("the key %s is already another declared entry's", layerKey) - case !matches(layerKey): - return refuse("the key %s this node's layer names is not %s", layerKey, what) - } - e.How, e.Key = adopted, layerKey - return e - } - // 4. By the match. - var found []string - for _, k := range candidates { - if !taken[k] && !ownMeshKey.MatchString(k) && !ownIdentity.MatchString(k) && matches(k) { - found = append(found, k) - } - } - switch len(found) { - case 0: - e.How, e.Key = created, own - case 1: - e.How, e.Key = adopted, found[0] - default: - return refuse("%d entries in the profile are %s (%s): %s (novox/hq ADR 0329 §7)", len(found), what, - strings.Join(found, ", "), remedy) - } - return e -} - -// sameURL compares two addresses without a trailing slash's difference. -func sameURL(a, b string) bool { - return a != "" && strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/")) -} - -func pref(name, value string) string { return fmt.Sprintf("user_pref(%s, %s);", quoted(name), value) } - -func boolWord(b bool) string { return strconv.FormatBool(b) } - -// identityLines are an identity's declared fields under its key. The default identity's name and address are -// the setting accounts' (§4), so only another identity carries them here. -func identityLines(e resolvedEntry, id *Identity, acct resolvedAccount) []string { - p := "mail.identity." + e.Key + "." - var out []string - if id.Address != "" { - out = append(out, pref(p+"fullName", jsQuoted(id.Name)), pref(p+"useremail", jsQuoted(id.Address))) - if e.How == created { - out = append(out, pref(p+"valid", "true"), pref(p+"smtpServer", quoted(acct.Keys.SMTP))) - } - } - above := 0 - if id.SignatureAboveQuote { - above = 1 - } - out = append(out, - pref(p+"reply_to", jsQuoted(id.ReplyTo)), - pref(p+"organization", jsQuoted(id.Organization)), - pref(p+"htmlSigText", jsQuoted(strings.Join(id.Signature, "\n"))), - pref(p+"htmlSigFormat", boolWord(id.SignatureHTML)), - pref(p+"attach_signature", "false"), - pref(p+"reply_on_top", strconv.Itoa(above)), - pref(p+"sig_bottom", boolWord(!id.SignatureAboveQuote))) - return out -} - -func calendarLines(e resolvedEntry, cal *Calendar, identityKey string) []string { - p := "calendar.registry." + e.Key + "." - var out []string - if e.How == created { - out = append(out, pref(p+"type", quoted("caldav")), pref(p+"cache.enabled", "true"), - pref(p+"calendar-main-in-composite", "true")) - } - reminders := cal.Reminders == nil || *cal.Reminders - out = append(out, pref(p+"uri", jsQuoted(cal.URL)), pref(p+"name", jsQuoted(cal.Name))) - if cal.Color != "" { - out = append(out, pref(p+"color", jsQuoted(strings.ToLower(cal.Color)))) - } - out = append(out, pref(p+"readOnly", boolWord(cal.ReadOnly)), pref(p+"suppressAlarms", boolWord(!reminders)), - pref(p+"username", jsQuoted(cal.User)), pref(p+"imip.identity.key", jsQuoted(identityKey))) - return out -} - -func addressBookLines(e resolvedEntry, ab *AddressBook, name string) []string { - p := "ldap_2.servers." + e.Key + "." - var out []string - if e.How == created { - out = append(out, pref(p+"dirType", "102"), pref(p+"filename", quoted("abook-mesh_"+name+".sqlite"))) - } - return append(out, pref(p+"description", jsQuoted(ab.Name)), pref(p+"carddav.url", jsQuoted(ab.URL)), - pref(p+"carddav.username", jsQuoted(ab.User))) -} - -func tagLines(key string, t *Tag) []string { - p := "mailnews.tags." + key + "." - out := []string{pref(p+"tag", jsQuoted(t.Name))} - if t.Color != "" { - out = append(out, pref(p+"color", jsQuoted(strings.ToUpper(t.Color)))) - } - if t.Order > 0 { - out = append(out, pref(p+"ordinal", quoted(tagOrdinal(t.Order)))) - } - return out -} - -// configBlock is the user.js block of the five kinds. -func (p configPlan) configBlock() string { - if len(p.lines) == 0 { - return "" - } - return "\n// The identities, calendars, address books and tags the settings declare (novox/hq ADR 0329), under the\n" + - "// keys each was adopted or created under. No password, token or cookie.\n" + strings.Join(p.lines, "\n") + "\n" -} - -// closedFor says why the profile's own files may not be written now, or "": Betterbird runs, or the profile's -// lock says one holds it (§9). -func (m *Machine) closedFor(profile string) string { - if ps := m.mainProcs(); len(ps) > 0 { - return fmt.Sprintf("Betterbird is running (pid %d), and it rewrites these files when it quits", ps[0].PID) - } - if _, err := os.Lstat(filepath.Join(profile, "lock")); err == nil { - return "the profile is locked (its lock file is there): a Betterbird holds it, or one ended without quitting" - } - return "" -} - -// prefsChanges is prefs.js as the plan wants it, and how many changes each kind has in it. -func (p configPlan) prefsChanges(text string) (string, map[string]int) { - counts := map[string]int{} - var lines []string - done := map[string]bool{} - s := bufio.NewScanner(strings.NewReader(text)) - s.Buffer(make([]byte, 64<<10), 4<<20) - for s.Scan() { - line := s.Text() - mm := prefLine.FindStringSubmatch(line) - if mm == nil { - lines = append(lines, line) - continue - } - name := mm[1] - removed := false - for _, prefix := range p.removals { - if strings.HasPrefix(name, prefix) { - removed = true - counts[kindOfPrefix(prefix)]++ - break - } - } - if removed { - continue - } - if acct, ok := strings.CutPrefix(name, "mail.account."); ok && strings.HasSuffix(acct, ".identities") { - key := strings.TrimSuffix(acct, ".identities") - if want, ok := p.identityLists[key]; ok { - done[name] = true - next := listedWithOwn(listOf(mm[2]), want, ownIdentity.MatchString, ",") - if next != strings.Join(listOf(mm[2]), ",") { - line = pref(name, quoted(next)) - counts[kindIdentities]++ - } - } - } - if name == "calendar.list.sortOrder" { - done[name] = true - have := strings.Fields(unquote(mm[2])) - next := listedWithOwn(have, p.calendarOrder, ownMeshKey.MatchString, " ") - if next != strings.Join(have, " ") { - line = pref(name, quoted(next)) - counts[kindCalendars]++ - } - } - lines = append(lines, line) - } - if !done["calendar.list.sortOrder"] && len(p.calendarOrder) > 0 { - lines = append(lines, pref("calendar.list.sortOrder", quoted(strings.Join(p.calendarOrder, " ")))) - counts[kindCalendars]++ - } - out := strings.Join(lines, "\n") - if text == "" || strings.HasSuffix(text, "\n") { - out += "\n" - } - return out, counts -} - -func kindOfPrefix(prefix string) string { - switch { - case strings.HasPrefix(prefix, "mail.identity."): - return kindIdentities - case strings.HasPrefix(prefix, "calendar."): - return kindCalendars - case strings.HasPrefix(prefix, "ldap_2."): - return kindAddressBooks - } - return kindTags -} - -// listedWithOwn is a list with the module's own keys made the wanted ones, every other key in its place. -func listedWithOwn(have, wanted []string, own func(string) bool, sep string) string { - want := map[string]bool{} - for _, k := range wanted { - want[k] = true - } - var out []string - there := map[string]bool{} - for _, k := range have { - if own(k) && !want[k] { - continue - } - out = append(out, k) - there[k] = true - } - for _, k := range wanted { - if !there[k] { - out = append(out, k) - } - } - return strings.Join(out, sep) -} - -// Waiting counts, per kind, the changes of what Betterbird rewrites that wait for it to close (§9). -func (m *Machine) waiting(profile string, p configPlan, ra []resolvedAccount) map[string]int { - raw, _ := readBounded(filepath.Join(profile, "prefs.js")) - _, counts := p.prefsChanges(string(raw)) - var wantAccounts, wantSMTP []string - for _, r := range ra { - if r.How == created { - wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP) - } - } - if _, changed := withListed(string(raw), map[string][]string{ - "mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}); changed { - counts["accounts"]++ - } - for _, f := range p.filters { - if f.changes() { - counts[kindFilters]++ - } - } - for k, v := range counts { - if v == 0 { - delete(counts, k) - } - } - return counts -} - -// writeClosed writes what Betterbird rewrites itself, only while it is closed: prefs.js's lists and the -// removals, and each declared account's filter file, keeping the file it replaces at its first write. It -// answers what it wrote, or why it waits. -func (m *Machine) writeClosed(profile Profile, p *configPlan) (string, error) { - var said []string - path := filepath.Join(profile.path, "prefs.js") - raw, err := readBounded(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", err - } - next, counts := p.prefsChanges(string(raw)) - pending := next != string(raw) - for _, f := range p.filters { - pending = pending || f.changes() - } - if !pending { - return "unchanged", nil - } - if why := m.closedFor(profile.path); why != "" { - return "", errors.New(why + ": the identities' and calendars' lists, the removals and the filter files are " + - "written at its next start from the launcher, or by betterbird_restart") - } - if next != string(raw) { - mode := os.FileMode(0o600) - if st, err := os.Stat(path); err == nil { - mode = st.Mode().Perm() - } - if err := writeAtomic(path, []byte(next), mode); err != nil { - return "", err - } - said = append(said, fmt.Sprintf("prefs.js: %d change(s) to the lists and removals", sum(counts))) - } - for i, f := range p.filters { - if !f.changes() { - continue - } - was := p.record.Filters[f.Account] - if was.Kept == "" && f.exists { - kept := m.keptFilterFile(profile.Dir, f.Account) - if err := writeAtomic(kept, []byte(f.have), 0o600); err != nil { - return strings.Join(said, "; "), fmt.Errorf("the filter file of %s was not written: the file it replaces could not be kept: %v", f.Account, err) - } - was.Kept = m.tilde(strings.TrimPrefix(kept, m.Root)) - } - mode := os.FileMode(0o644) - if st, err := os.Stat(f.path); err == nil { - mode = st.Mode().Perm() - } - if err := writeAtomic(f.path, []byte(f.want), mode); err != nil { - return strings.Join(said, "; "), err - } - was.File = f.File - p.record.Filters[f.Account] = was - p.filters[i].have, p.filters[i].exists = f.want, true - said = append(said, "the filters of "+f.Account) - } - if len(said) == 0 { - return "unchanged", nil - } - return "written: " + strings.Join(said, "; "), nil -} - -func sum(m map[string]int) int { - n := 0 - for _, v := range m { - n += v - } - return n -} - -// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "" when -// they name none within the profile. -func (m *Machine) accountDirectory(profile string, prefs map[string]string, server string) string { - if dir := accountDirectory(profile, prefs, server); dir != "" { - return dir - } - abs := unquote(prefs["mail.server."+server+".directory"]) - if abs == "" { - return "" - } - dir := m.path(abs) - if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) { - return dir - } - return "" -} - -func mustJSON(v any) string { - b, _ := json.Marshal(v) - return string(bytes.TrimSpace(b)) -} diff --git a/modules/betterbird/cmd/betterbird-tools/prefs.go b/modules/betterbird/cmd/betterbird-tools/prefs.go deleted file mode 100644 index 1d4b9dca..00000000 --- a/modules/betterbird/cmd/betterbird-tools/prefs.go +++ /dev/null @@ -1,228 +0,0 @@ -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 -} diff --git a/modules/betterbird/cmd/betterbird-tools/profilekinds.go b/modules/betterbird/cmd/betterbird-tools/profilekinds.go deleted file mode 100644 index 8c57a67c..00000000 --- a/modules/betterbird/cmd/betterbird-tools/profilekinds.go +++ /dev/null @@ -1,178 +0,0 @@ -package main - -// The profile's identities, calendars, address books and tags, read from its preferences into the setting's -// shape (novox/hq ADR 0329). The profile is read as Betterbird starts with it: prefs.js, with user.js over it. -// Nothing here reads the login store, a calendar's events or an address book's contacts (§13). - -import ( - "path/filepath" - "sort" - "strconv" - "strings" -) - -// profileIdentity is one identity the profile holds. -type profileIdentity struct { - Key string - AccountKey string - // Default says it is its account's first identity. - Default bool - Owl bool - Value Identity -} - -// profileIdentities reads every identity of every account in the profile's list, the default first. -func (m *Machine) profileIdentities(prefs map[string]string) []profileIdentity { - var out []profileIdentity - for _, a := range profileAccounts(prefs) { - for i, id := range a.Identities { - out = append(out, profileIdentity{Key: id, AccountKey: a.Key, Default: i == 0, Owl: a.Owl, - Value: m.readIdentity(prefs, id)}) - } - } - return out -} - -// readIdentity reads one identity's fields. A signature the profile reads from a file is read as its text, since -// a file's path differs per node (§10). -func (m *Machine) readIdentity(prefs map[string]string, key string) Identity { - p := "mail.identity." + key + "." - text := func(name string) string { return unquote(prefs[p+name]) } - id := Identity{Address: text("useremail"), Name: text("fullName"), ReplyTo: text("reply_to"), - Organization: text("organization"), SignatureHTML: prefs[p+"htmlSigFormat"] == "true", - SignatureAboveQuote: prefs[p+"reply_on_top"] == "1" && prefs[p+"sig_bottom"] == "false"} - sig := text("htmlSigText") - if prefs[p+"attach_signature"] == "true" { - if file := text("sig_file"); file != "" { - sig = m.signatureFile(file) - } - } - if sig = strings.TrimRight(strings.ReplaceAll(sig, "\r\n", "\n"), "\n"); sig != "" { - id.Signature = strings.Split(sig, "\n") - } - return id -} - -// signatureFile is a signature file's text, read only within the home and bounded. -func (m *Machine) signatureFile(file string) string { - clean := filepath.Clean(file) - if !strings.HasPrefix(clean, strings.TrimSuffix(m.Home, "/")+"/") { - return "" - } - raw, err := readBounded(m.path(clean)) - if err != nil || len(raw) > 64<<10 { - return "" - } - return string(raw) -} - -// keysUnder are the distinct second-level keys of the preferences under a prefix: calendar.registry..…. -func keysUnder(prefs map[string]string, prefix string) []string { - seen := map[string]bool{} - for name := range prefs { - if rest, ok := strings.CutPrefix(name, prefix); ok { - if key, _, found := strings.Cut(rest, "."); found && key != "" { - seen[key] = true - } - } - } - return sortedKeys(seen) -} - -// profileCalendar is one calendar of the profile: Kind is Betterbird's (caldav, ics, storage, owl …). -type profileCalendar struct { - Key string - Kind string - // IdentityKey is the identity that answers its invitations. - IdentityKey string - Value Calendar -} - -func profileCalendars(prefs map[string]string) []profileCalendar { - var out []profileCalendar - for _, key := range keysUnder(prefs, "calendar.registry.") { - p := "calendar.registry." + key + "." - text := func(name string) string { return unquote(prefs[p+name]) } - c := profileCalendar{Key: key, Kind: text("type"), IdentityKey: text("imip.identity.key"), - Value: Calendar{Name: text("name"), URL: text("uri"), Color: strings.ToLower(text("color")), - ReadOnly: prefs[p+"readOnly"] == "true", User: text("username")}} - if prefs[p+"suppressAlarms"] == "true" { - off := false - c.Value.Reminders = &off - } - if c.Kind == "" && c.Value.URL == "" { - continue - } - out = append(out, c) - } - return out -} - -// profileAddressBook is one address book of the profile: CardDAV is the kind a setting holds. -type profileAddressBook struct { - Key string - CardDAV bool - Kind string - Value AddressBook -} - -// Betterbird's kinds of address book (dirType). -var addressBookKinds = map[string]string{"0": "LDAP", "101": "local", "102": "CardDAV"} - -func profileAddressBooks(prefs map[string]string) []profileAddressBook { - var out []profileAddressBook - for _, key := range keysUnder(prefs, "ldap_2.servers.") { - p := "ldap_2.servers." + key + "." - if _, has := prefs[p+"dirType"]; !has && prefs[p+"description"] == "" { - continue - } - kind := addressBookKinds[prefs[p+"dirType"]] - if kind == "" { - kind = "other" - } - out = append(out, profileAddressBook{Key: key, CardDAV: kind == "CardDAV", Kind: kind, - Value: AddressBook{Name: unquote(prefs[p+"description"]), URL: unquote(prefs[p+"carddav.url"]), - User: unquote(prefs[p+"carddav.username"])}}) - } - return out -} - -// profileTag is one tag of the profile. -type profileTag struct { - Key string - ordinal string - Value Tag -} - -// profileTags reads the tags, each with its place as Betterbird orders them. -func profileTags(prefs map[string]string) []profileTag { - var out []profileTag - for _, key := range keysUnder(prefs, "mailnews.tags.") { - p := "mailnews.tags." + key + "." - if _, has := prefs[p+"tag"]; !has { - continue - } - out = append(out, profileTag{Key: key, ordinal: unquote(prefs[p+"ordinal"]), - Value: Tag{Name: unquote(prefs[p+"tag"]), Color: strings.ToUpper(unquote(prefs[p+"color"]))}}) - } - // Betterbird orders the tags by their ordinal, a tag without one by its key in its place. - at := func(t profileTag) string { - if t.ordinal != "" { - return t.ordinal - } - return t.Key - } - sort.SliceStable(out, func(i, j int) bool { - if at(out[i]) != at(out[j]) { - return at(out[i]) < at(out[j]) - } - return out[i].Key < out[j].Key - }) - for i := range out { - out[i].Value.Order = i + 1 - } - return out -} - -// tagOrdinal is the ordinal a tag's place is written as, so Betterbird's order by text is the declared one. -func tagOrdinal(order int) string { return strconv.Itoa(1000 + order) } diff --git a/modules/betterbird/cmd/betterbird-tools/reminder.go b/modules/betterbird/cmd/betterbird-tools/reminder.go deleted file mode 100644 index d60cc684..00000000 --- a/modules/betterbird/cmd/betterbird-tools/reminder.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -// A reminder on demand, without the operator's calendar: a second Betterbird, in a profile of its own -// under the account's runtime directory, with one local calendar file holding one event whose reminder -// is due a few seconds after the start. The running Betterbird, its profile, its accounts and its -// calendars are not touched: the second one starts with --no-remote, so it neither hands over to the -// first nor is handed to. -// -// The test profile gets the module's user.js too, so the reminder is drawn with the preferences the -// module declares. A theme or another preference of the operator's profile can be carried over by -// name (carry), within the same limits as betterbird_prefs: never a credential, never an account. -// -// **It is visibly a test** (the operator, 2026-10-12): on 2026-10-12 at 12:05 a test Betterbird with an empty -// profile carrying the operator's theme looked like the operator's configuration lost. So the test profile -// carries a userChrome.css that marks its main window with a red band reading MESH TEST, and the event is -// called "MESH TEST reminder". The reminder window itself is left as Betterbird draws it, because how it is -// drawn is what the test looks at. The tool's description says a window opens on the operator's screen, so -// a caller tells the operator first. -// -// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the -// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and -// looked at with xorg_screenshot. - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" -) - -// testDir is the test's profile and calendar, under the account's runtime directory. -func (m *Machine) testDir() string { - return m.path(fmt.Sprintf("/run/user/%d/%s", m.UID, testUnit)) -} - -// carryName is a preference name the test may carry over from the operator's profile. -var carryName = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`) - -// ReminderAnswer is what betterbird_reminder_test answers. -type ReminderAnswer struct { - Unit string `json:"unit"` - Profile string `json:"profile"` - Event string `json:"event"` - Due string `json:"reminder_due"` - Carried []string `json:"carried,omitempty"` - Withheld []string `json:"withheld,omitempty"` - Look string `json:"look"` - OnScreen string `json:"on_screen,omitempty"` - Stopped bool `json:"stopped,omitempty"` -} - -// ics is the test calendar: one event, its reminder `after` from now. -func ics(now time.Time, after time.Duration, stamp string) string { - f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") } - start := now.Add(after + 10*time.Minute) - return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN", - "BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start), - "DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:" + testTitle, "BEGIN:VALARM", "ACTION:DISPLAY", - "DESCRIPTION:" + testTitle, "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n") -} - -// testTitle is the test event's title: what the reminder window shows. -const testTitle = "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)" - -// testChrome is the test profile's userChrome.css: a red band reading MESH TEST across the top of every -// Betterbird window but the reminder (windowtype Calendar:AlarmWindow), and a red frame around it, so the test -// Betterbird is never taken for the operator's. -const testChrome = `/* The mesh's reminder test (module betterbird, novox/hq issue 345): a throw-away Betterbird, not yours. */ -:root:not([windowtype="Calendar:AlarmWindow"]) { - outline: 6px solid #d00000 !important; - outline-offset: -6px !important; -} -:root:not([windowtype="Calendar:AlarmWindow"]) > body::before { - content: "MESH TEST \2014 a throw-away Betterbird started by the mesh for a reminder test; your mail and calendar are not here. It ends by itself within 10 minutes."; - display: block !important; - background: #d00000 !important; - color: #ffffff !important; - font: bold 15px sans-serif !important; - padding: 6px 12px !important; - text-align: center !important; -} -` - -// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions. -// -// betterbird.update.checkIntervalDays 0 is Betterbird's own switch for its update check (MailGlue): a -// fresh profile has never checked, so Betterbird checks at its start and, when betterbird.eu publishes a -// newer build than the installed one, opens the About window on the operator's screen and gives it the -// focus (seen on the workstation 2026-10-12, novox/hq issue 345). The test is about the reminder only. -func testPrefs(calendar string) string { - return strings.Join([]string{ - `user_pref("calendar.registry.meshtest.type", "ics");`, - `user_pref("calendar.registry.meshtest.uri", "file://` + calendar + `");`, - `user_pref("calendar.registry.meshtest.name", "Mesh reminder test");`, - `user_pref("calendar.registry.meshtest.calendar-main-in-composite", true);`, - `user_pref("calendar.registry.meshtest.suppressAlarms", false);`, - `user_pref("calendar.list.sortOrder", "meshtest");`, - `user_pref("mail.provider.suppress_dialog_on_startup", true);`, - `user_pref("mail.shell.checkDefaultClient", false);`, - `user_pref("datareporting.policy.dataSubmissionEnabled", false);`, - `user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`, - `user_pref("app.update.auto", false);`, - `user_pref("betterbird.update.checkIntervalDays", 0);`, - `user_pref("mail.rights.version", 1);`, - `user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`, - "", - }, "\n") -} - -// ReminderTest starts the test Betterbird, or with stop ends it and removes its profile. -func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (ReminderAnswer, error) { - dir := m.testDir() - a := ReminderAnswer{Unit: testUnit + ".service", Profile: strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root), - Look: "xorg_windows with class Calendar answers the reminder window (role AlarmWindow, its depth and map state); " + - "xorg_screenshot with that window shows it as the screen does"} - if stop { - _ = m.cmd(5*time.Second, m.bus(), "systemctl", "--user", "stop", testUnit+".service") - if err := os.RemoveAll(dir); err != nil { - return a, err - } - a.Stopped, a.Look = true, "" - return a, nil - } - s, err := m.session() - if err != nil { - return a, err - } - var carried []string - if len(carry) > 0 { - p, err := m.profile() - if err != nil { - return a, err - } - prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js")) - have := readPrefs(prefsJS) - for _, n := range carry { - switch { - case !carryName.MatchString(n): - return a, fmt.Errorf("carry holds preference names, not %q", n) - case withheld(n) != "": - a.Withheld = append(a.Withheld, n+": "+withheld(n)) - case have[n] == "": - a.Withheld = append(a.Withheld, n+": not set in the operator's profile") - default: - carried = append(carried, fmt.Sprintf("user_pref(%s, %s);", strconv.Quote(n), have[n])) - a.Carried = append(a.Carried, n) - } - } - } - _ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", testUnit+".service") - if err := os.RemoveAll(dir); err != nil { - return a, err - } - if err := os.MkdirAll(filepath.Join(dir, "profile"), 0o700); err != nil { - return a, err - } - now := m.Now() - calendar := filepath.Join(dir, "test.ics") - if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil { - return a, err - } - // The module's user.js first, then what is carried, then the test's own preferences: the last - // statement wins, so neither can take the test's calendar or its quiet start away. - user := "" - if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil { - user = mine + "\n" - } - user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root)) - if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil { - return a, err - } - if err := os.MkdirAll(filepath.Join(dir, "profile", "chrome"), 0o700); err != nil { - return a, err - } - if err := os.WriteFile(filepath.Join(dir, "profile", "chrome", "userChrome.css"), []byte(testChrome), 0o600); err != nil { - return a, err - } - // The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the - // test Betterbird is closed. - call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600", - "-p", "ExecStopPost=/usr/bin/rm -rf " + strings.TrimPrefix(dir, m.Root), "--setenv=MOZ_NO_REMOTE=1"} - for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { - if kv[1] != "" { - call = append(call, "--setenv="+kv[0]+"="+kv[1]) - } - } - call = append(call, "--", startBin, "--no-remote", "--profile", strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root)) - if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil { - return a, err - } - a.Event = testTitle + ", in a calendar file of the test profile only" - a.OnScreen = "a second Betterbird's window is now on the operator's screen, marked MESH TEST in red; its reminder " + - "window follows when the reminder is due. Both go when the test ends (stop, or 10 minutes)" - a.Due = now.Add(after).UTC().Format(time.RFC3339) - return a, nil -} diff --git a/modules/betterbird/cmd/betterbird-tools/review_test.go b/modules/betterbird/cmd/betterbird-tools/review_test.go deleted file mode 100644 index ab12a4ac..00000000 --- a/modules/betterbird/cmd/betterbird-tools/review_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "context" - "strings" - "testing" - "time" -) - -// The review of pull request 155 (hq issue 345): a restart never loses an unsent message unasked, a -// preference's value never carries an address, a log never a credential, the reminder test never -// counts as the operator's Betterbird and leaves nothing behind, and a restart answers within the -// runtime's call limit. - -const composerTree = ` - 0x2a00003 "Inbox - Betterbird": ("Mail" "eu.betterbird.Betterbird") 2560x1388+0+0 +0+26 - 0x2a01234 "Write: hello - Betterbird": ("Msgcompose" "eu.betterbird.Betterbird") 900x700+10+10 +10+10 -` - -func TestARestartRefusesWhileAComposerIsOpenUnlessTheUnsentMayGo(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.declare("// none") - f.answer = func(name string, args []string) Output { - if name == "xwininfo" { - return Output{Stdout: composerTree} - } - return Output{} - } - if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") { - t.Fatalf("refused while a message is being written: %v", err) - } - if len(f.signals) != 0 { - t.Fatalf("nothing was ended: %v", f.signals) - } - f.onStart = func([]string) { - f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service") - } - a, err := f.Restart(true) - if err != nil || len(a.Ended) != 1 || a.Composers != 1 { - t.Fatalf("with discard_unsent it goes ahead and says what was open: %+v %v", a, err) - } -} - -func TestARestartAnswersWithinTheCallLimitEvenWhenNothingCooperates(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.stubborn[4000] = true - slept := time.Duration(0) - f.Sleep = func(d time.Duration) { slept += d } - a, err := f.Restart(false) - if err != nil { - t.Fatalf("a start that has not shown yet is said, not failed: %v", err) - } - if slept > 18*time.Second || len(a.Killed) != 1 || a.Note == "" { - t.Fatalf("waited %v of the runtime's 30 s: %+v", slept, a) - } -} - -func TestAPreferencesValueCarriesNoAddressHostOrHome(t *testing.T) { - f := newFake(t) - f.betterbird() - links(t, nil) - f.write(profileDir+"/prefs.js", strings.Join([]string{ - `user_pref("mail.last_msg_movecopy_target_uri", "imap://someone%40example.org@mail.example.org/INBOX");`, - `user_pref("messenger.account.a1.server", "x");`, - `user_pref("mail.cloud_files.accounts.a", "y");`, - `user_pref("editor.lastFileLocation", "/home/operator/Documents/a.pdf");`, - `user_pref("network.proxy.http", "proxy.corp.example.com");`, - `user_pref("mail.compose.default_to", "imap://someone%40example.org@mail.example.org/x and bob@example.org");`, - `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`, - `user_pref("extensions.ui.lastCategory", "addons://list/theme");`, - `user_pref("font.size.variable.x-western", 12);`, - }, "\n")+"\n") - got, err := f.Prefs([]string{"mail.last_msg*", "messenger.account.", "mail.cloud_files.", "editor.", "network.proxy.", - "mail.compose.default_to", "extensions.", "font.size."}) - if err != nil { - t.Fatal(err) - } - by := map[string]Pref{} - for _, p := range got["prefs"].([]Pref) { - by[p.Name] = p - } - for _, n := range []string{"mail.last_msg_movecopy_target_uri", "messenger.account.a1.server", "mail.cloud_files.accounts.a", "extensions.ui.lastCategory"} { - if p := by[n]; p.Value != "" || p.Withheld == "" { - t.Errorf("withheld: %s %+v", n, p) - } - } - if v := by["editor.lastFileLocation"].Value; v != `"~/Documents/a.pdf"` { - t.Errorf("home as ~: %s", v) - } - if v := by["network.proxy.http"].Value; strings.Contains(v, "example") { - t.Errorf("a host: %s", v) - } - if v := by["mail.compose.default_to"].Value; strings.Contains(v, "example") || strings.Contains(v, "someone") { - t.Errorf("an address: %s", v) - } - if v := by["extensions.activeThemeID"].Value; v != `"thunderbird-compact-dark@mozilla.org"` { - t.Errorf("a theme's id is answered as it is: %s", v) - } - if v := by["font.size.variable.x-western"].Value; v != "12" { - t.Errorf("%s", v) - } - for _, bad := range [][]string{{""}, {"*"}, {"."}, {" "}} { - if _, err := f.Prefs(bad); err == nil { - t.Errorf("%q is refused: it asks for everything", bad) - } - } -} - -func TestALogLineKeepsNoBearerOrBasicCredential(t *testing.T) { - for _, l := range []string{"Authorization: Bearer eyJhbGciOi.abc.def", "auth header basic dXNlcjpwYXNz sent", "token=abc123"} { - got := mask(l) - for _, secret := range []string{"eyJhbGciOi", "dXNlcjpwYXNz", "abc123"} { - if strings.Contains(got, secret) { - t.Errorf("%q → %q", l, got) - } - } - } -} - -func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.proc(6000, 1000, "betterbird-bin", []string{"/usr/bin/betterbird", "--no-remote", "--profile", "/run/user/1000/" + testUnit + "/profile"}, testUnit+".service") - if ps := f.mainProcs(); len(ps) != 1 || ps[0].PID != 4000 { - t.Fatalf("only the operator's: %+v", ps) - } - f.declare("// none") - if _, err := f.ReminderTest(40*time.Second, nil, false); err != nil { - t.Fatal(err) - } - if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit) { - t.Fatalf("the unit removes its profile when it ends: %v", f.calls) - } -} - -func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.answer = func(name string, args []string) Output { - if name == "xwininfo" { - return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"} - } - return Output{} - } - if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") { - t.Fatalf("a composer of another class is still a composer: %v", err) - } -} - -func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) { - f := newFake(t) - f.desktopSession() - f.betterbird() - links(t, nil) - f.stubborn[4000] = true - total := time.Duration(0) - run := f.Run - f.Run = func(ctx context.Context, env []string, name string, args ...string) Output { - // Every command takes its whole timeout: the worst case. - if d, ok := ctx.Deadline(); ok { - total += time.Until(d).Round(time.Second) - } - return run(ctx, env, name, args...) - } - f.Sleep = func(d time.Duration) { total += d } - if _, err := f.Restart(true); err != nil { - t.Fatal(err) - } - if total > 26*time.Second { - t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total) - } -} diff --git a/modules/betterbird/cmd/betterbird-tools/setup_test.go b/modules/betterbird/cmd/betterbird-tools/setup_test.go deleted file mode 100644 index c059700a..00000000 --- a/modules/betterbird/cmd/betterbird-tools/setup_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "os" - "testing" -) - -// No test reaches the network or the mesh: Betterbird's list of published builds and the controller's -// settings verb answer from here, and a test that needs them says what they answer. -func TestMain(m *testing.M) { - publishedBuilds = func() (string, error) { return "", errors.New("no network in tests") } - asker = func(string, any) (json.RawMessage, error) { return nil, errors.New("no mesh in tests") } - os.Exit(m.Run()) -} diff --git a/modules/betterbird/cmd/betterbird-tools/templates_test.go b/modules/betterbird/cmd/betterbird-tools/templates_test.go deleted file mode 100644 index 7d0718b5..00000000 --- a/modules/betterbird/cmd/betterbird-tools/templates_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// The module's directory, where its manifest and its files/ are. -const moduleDir = "../.." - -// templateText is the text a file resource places on the machine: its template's, in the module's files/ -// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. -func templateText(t *testing.T, r map[string]any) string { - t.Helper() - name, _ := r["template"].(string) - if name == "" { - t.Fatalf("resource %v names no template", r["id"]) - } - raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name))) - if err != nil { - t.Fatalf("resource %v names %s: %v", r["id"], name, err) - } - return string(raw) -} - -// No file resource carries its text inline: each names its template in files/, and the build packs files/ -// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). -func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { - raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) - if err != nil { - t.Fatal(err) - } - var m struct { - Resources []map[string]any `json:"resources"` - Build struct { - Artifacts []map[string]any `json:"artifacts"` - } `json:"build"` - } - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatal(err) - } - for _, r := range m.Resources { - if _, inline := r["content"]; inline { - t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) - } - if r["type"] == "file" { - templateText(t, r) - } - } - packs := 0 - for _, a := range m.Build.Artifacts { - if a["kind"] == "archive" && a["from"] == "files" { - packs++ - if a["name"] != "files" { - t.Errorf("the templates archive is named %v, not files", a["name"]) - } - } - } - if packs != 1 { - t.Errorf("the build packs files/ into %d archives, not one", packs) - } -} diff --git a/modules/betterbird/files/accounts.json b/modules/betterbird/files/accounts.json deleted file mode 100644 index e182cc3d..00000000 --- a/modules/betterbird/files/accounts.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "accounts": {} -} diff --git a/modules/betterbird/files/betterbird b/modules/betterbird/files/betterbird deleted file mode 100755 index 3ce088a8..00000000 --- a/modules/betterbird/files/betterbird +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push. -# -# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars, -# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do -# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a -# second start while one runs only hands over to it. What the preparation could not do it says here, and -# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back. -/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2 -exec /opt/betterbird/betterbird "$@" diff --git a/modules/betterbird/files/configuration.json b/modules/betterbird/files/configuration.json deleted file mode 100644 index 70204462..00000000 --- a/modules/betterbird/files/configuration.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "identities": {}, - "calendars": {}, - "address-books": {}, - "filters": {} -} diff --git a/modules/betterbird/files/eu.betterbird.Betterbird.desktop b/modules/betterbird/files/eu.betterbird.Betterbird.desktop deleted file mode 100644 index e409fcf8..00000000 --- a/modules/betterbird/files/eu.betterbird.Betterbird.desktop +++ /dev/null @@ -1,25 +0,0 @@ -[Desktop Entry] -Version=1.0 -Type=Application -Name=Betterbird -GenericName=Mail Client -Comment=Send and receive mail with Betterbird -Exec=/usr/local/bin/betterbird %u -Icon=/opt/betterbird/chrome/icons/default/default128.png -Terminal=false -Categories=Network;Email; -MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid; -StartupNotify=true -StartupWMClass=eu.betterbird.Betterbird -Keywords=Email;E-mail;Calendar;Thunderbird; -Actions=ComposeMessage;OpenAddressBook; -# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry: -# it starts Betterbird through the module's start, and names the icon in the installation, with no link. - -[Desktop Action ComposeMessage] -Name=Write new message -Exec=/usr/local/bin/betterbird -compose - -[Desktop Action OpenAddressBook] -Name=Open address book -Exec=/usr/local/bin/betterbird -addressbook diff --git a/modules/betterbird/files/prefs.json b/modules/betterbird/files/prefs.json deleted file mode 100644 index 45fd9732..00000000 --- a/modules/betterbird/files/prefs.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "prefs": {} -} diff --git a/modules/betterbird/files/tags.json b/modules/betterbird/files/tags.json deleted file mode 100644 index 4772f099..00000000 --- a/modules/betterbird/files/tags.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "tags": {} -} diff --git a/modules/betterbird/go.mod b/modules/betterbird/go.mod deleted file mode 100644 index 49710a9c..00000000 --- a/modules/betterbird/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module betterbird - -go 1.22 - -require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/betterbird/go.sum b/modules/betterbird/go.sum deleted file mode 100644 index b474419e..00000000 --- a/modules/betterbird/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/betterbird/install/defaults-pref/mesh-defaults.js b/modules/betterbird/install/defaults-pref/mesh-defaults.js deleted file mode 100644 index 3cd76a27..00000000 --- a/modules/betterbird/install/defaults-pref/mesh-defaults.js +++ /dev/null @@ -1,20 +0,0 @@ -// Written by the mesh (module betterbird, novox/hq issue 345, issue 586): the preferences the module manages at -// Betterbird's own default. It holds no value of the operator's: the operator's setup is the module's setting -// prefs, mesh-wide with a node's own layer over it, which the tools place in the profile's user.js, where it wins -// at every start; the policy's locks (distribution/policies.json) win over everything. betterbird_config shows the -// layers and the drift. Change it in the catalogue, never here. -// -// A line "keeps Betterbird's default" names a preference the module manages without writing a value: Betterbird's -// own default holds there, and a value the profile saved is drift, which betterbird_config_apply clears. - -// keeps Betterbird's default: extensions.activeThemeID -// The default theme (default-theme@mozilla.org) follows the desktop's dark mode. Betterbird keeps the active theme -// in its add-on database, so another theme is changed back in the add-on manager, never by clearing prefs.js. -// keeps Betterbird's default: browser.display.use_system_colors -// Betterbird's own colours, which the default theme is drawn for. -// keeps Betterbird's default: layout.css.always_underline_links -// Links are underlined only where the message's own style says so. -// keeps Betterbird's default: mail.spam.manualMark -// Marking a message as junk by hand does nothing more than mark it. -// keeps Betterbird's default: mail.compose.show_attachment_pane -// The composer shows its attachment pane only when a message has attachments. diff --git a/modules/betterbird/install/defaults-pref/vendor-prefs.js b/modules/betterbird/install/defaults-pref/vendor-prefs.js deleted file mode 100644 index b7452ce0..00000000 --- a/modules/betterbird/install/defaults-pref/vendor-prefs.js +++ /dev/null @@ -1,3 +0,0 @@ -// Written by the mesh (module betterbird, novox/hq ADR 0316 §3): the system's spelling dictionaries, named -// as a default preference in place of the link the community package made. -pref("spellchecker.dictionary_path", "/usr/share/hunspell"); diff --git a/modules/betterbird/install/distribution/policies.json b/modules/betterbird/install/distribution/policies.json deleted file mode 100644 index 4edb6abe..00000000 --- a/modules/betterbird/install/distribution/policies.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "policies": { - "DisableAppUpdate": true, - "DisableTelemetry": true, - "ExtensionUpdate": false, - "ExtensionSettings": { - "owl@beonex.com": { - "installation_mode": "force_installed", - "install_url": "file:///opt/betterbird/owl/owl.xpi", - "updates_disabled": true - } - }, - "Preferences": { - "mail.shell.checkDefaultClient": { - "Value": false, - "Status": "locked" - }, - "mail.inappnotifications.enabled": { - "Value": false, - "Status": "locked" - }, - "mailnews.start_page.enabled": { - "Value": false, - "Status": "locked" - } - } - } -} diff --git a/modules/betterbird/module.json b/modules/betterbird/module.json deleted file mode 100644 index 891f8832..00000000 --- a/modules/betterbird/module.json +++ /dev/null @@ -1,253 +0,0 @@ -{ - "module": "betterbird", - "version": "1", - "upgrade": { - "policy": "record", - "why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)" - }, - "requires": [ - "x11-display" - ], - "invokes": [ - "seat:mesh-controller.settings", - "seat:mesh-controller.nodes", - "seat:mesh-controller.node", - "betterbird.betterbird_derive" - ], - "tools": [ - "betterbird_status", - "betterbird_prefs", - "betterbird_config", - "betterbird_config_apply", - "betterbird_user_js", - "betterbird_log", - "betterbird_restart", - "betterbird_check", - "betterbird_accounts", - "betterbird_identities", - "betterbird_calendars", - "betterbird_address_books", - "betterbird_filters", - "betterbird_tags", - "betterbird_derive", - "betterbird_drift", - "betterbird_reminder_test" - ], - "config": { - "prefs": { - "type": "object", - "description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).", - "trusted": true, - "default": {} - }, - "accounts": { - "type": "object", - "description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).", - "trusted": true, - "default": {} - }, - "identities": { - "type": "object", - "description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.", - "trusted": true, - "default": {} - }, - "calendars": { - "type": "object", - "description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", - "trusted": true, - "default": {} - }, - "address-books": { - "type": "object", - "description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", - "trusted": true, - "default": {} - }, - "filters": { - "type": "object", - "description": "The message filters of each account, by account: what each matches and what it does with the mail.", - "trusted": true, - "default": {} - }, - "tags": { - "type": "object", - "description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.", - "trusted": true, - "default": {} - } - }, - "resources": [ - { - "id": "configuration-dir", - "type": "directory", - "path": "${machine:account-home}/.config/betterbird", - "owner": "${machine:account}", - "mode": "0755", - "unknown": "move-out", - "knows": ["accounts-record.json", "configuration-record.json", "kept/"] - }, - { - "id": "prefs", - "type": "file", - "path": "${machine:account-home}/.config/betterbird/prefs.json", - "owner": "${machine:account}", - "mode": "0644", - "merge": "json", - "takes-config": [ - "prefs" - ], - "template": "files/prefs.json", - "trusted": true - }, - { - "id": "accounts", - "type": "file", - "path": "${machine:account-home}/.config/betterbird/accounts.json", - "owner": "${machine:account}", - "mode": "0600", - "merge": "json", - "takes-config": [ - "accounts" - ], - "template": "files/accounts.json", - "trusted": true - }, - { - "id": "configuration", - "type": "file", - "path": "${machine:account-home}/.config/betterbird/configuration.json", - "owner": "${machine:account}", - "mode": "0600", - "merge": "json", - "takes-config": [ - "identities", - "calendars", - "address-books", - "filters" - ], - "template": "files/configuration.json", - "trusted": true - }, - { - "id": "tags", - "type": "file", - "path": "${machine:account-home}/.config/betterbird/tags.json", - "owner": "${machine:account}", - "mode": "0644", - "merge": "json", - "takes-config": [ - "tags" - ], - "template": "files/tags.json", - "trusted": true - }, - { - "id": "community-package", - "type": "package", - "package": "betterbird-bin", - "absent": true - }, - { - "id": "installation", - "type": "archive", - "path": "/opt/betterbird", - "artifact": "archive", - "unknown": "program" - }, - { - "id": "owl", - "type": "archive", - "path": "/opt/betterbird/owl", - "artifact": "owl", - "unknown": "program" - }, - { - "id": "policies", - "type": "archive", - "path": "/opt/betterbird/distribution", - "artifact": "distribution", - "unknown": "program" - }, - { - "id": "spelling", - "type": "archive", - "path": "/opt/betterbird/defaults/pref", - "artifact": "defaults", - "unknown": "program" - }, - { - "id": "preparation", - "type": "archive", - "path": "/usr/local/lib/mesh-betterbird", - "artifact": "tools", - "unknown": "program" - }, - { - "id": "start", - "type": "file", - "path": "/usr/local/bin/betterbird", - "mode": "0755", - "template": "files/betterbird" - }, - { - "id": "launcher", - "type": "file", - "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", - "mode": "0644", - "template": "files/eu.betterbird.Betterbird.desktop" - } - ], - "build": { - "artifacts": [ - { - "name": "tools", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/betterbird-tools", - "binary": "betterbird-tools", - "loads": [ - "betterbird-tools" - ] - }, - { - "name": "archive", - "kind": "archive", - "url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz", - "sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2", - "version": "153.4.0esr-bb10", - "within": "betterbird", - "names-on-purpose": { - "www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)" - } - }, - { - "name": "owl", - "kind": "archive", - "url": "https://www.beonex.com/owl/owl.xpi", - "sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221", - "version": "1.5.6.1", - "file": "owl.xpi", - "names-on-purpose": { - "www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)" - } - }, - { - "name": "distribution", - "kind": "archive", - "from": "install/distribution" - }, - { - "name": "defaults", - "kind": "archive", - "from": "install/defaults-pref" - }, - { - "name": "files", - "kind": "archive", - "from": "files" - } - ] - } -} diff --git a/modules/mesh-issues/files/settings.json b/modules/mesh-issues/files/settings.json index 92887e0e..340d44a0 100644 --- a/modules/mesh-issues/files/settings.json +++ b/modules/mesh-issues/files/settings.json @@ -9,7 +9,6 @@ "mesh-catalog modules/audit-logger", "mesh-catalog modules/avahi", "mesh-catalog modules/baserow", - "mesh-catalog modules/betterbird", "mesh-catalog modules/blueman", "mesh-catalog modules/bluetooth", "mesh-catalog modules/brother", diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index bb2601eb..32a95787 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -102,7 +102,6 @@ "mesh-catalog modules/audit-logger", "mesh-catalog modules/avahi", "mesh-catalog modules/baserow", - "mesh-catalog modules/betterbird", "mesh-catalog modules/blueman", "mesh-catalog modules/bluetooth", "mesh-catalog modules/brother",