Compare commits
247
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08188663fa | ||
|
|
d4a9260847 | ||
|
|
8b53a36632 | ||
|
|
5d98bcc0db | ||
|
|
2d1b20364f | ||
|
|
741b729ad4 | ||
|
|
3db85a0dde | ||
|
|
a31ad48b02 | ||
|
|
0260ae92f6 | ||
|
|
ee1b15d7f0 | ||
|
|
8efe499db9 | ||
|
|
173c5d5ef4 | ||
|
|
c7c784161d | ||
|
|
0fdacb9839 | ||
|
|
b0e4a12c88 | ||
|
|
a76b0742ff | ||
|
|
21a499ba59 | ||
|
|
de9d29dc65 | ||
|
|
62ec68355c | ||
|
|
6a5e6510ca | ||
|
|
3f97d2d50d | ||
|
|
6112233649 | ||
|
|
8c2d7e5f97 | ||
|
|
21ebbffed4 | ||
|
|
31120ed580 | ||
|
|
cf304d8629 | ||
|
|
12d7852082 | ||
|
|
c9848f4fb8 | ||
|
|
c070bea782 | ||
|
|
7443938fef | ||
|
|
aee97bf8ad | ||
|
|
c816cafc00 | ||
|
|
2353350d79 | ||
|
|
d4b8b24701 | ||
|
|
de6657c846 | ||
|
|
f75e1a91d9 | ||
|
|
ebfec39937 | ||
|
|
24d05701d3 | ||
|
|
a95b3fa053 | ||
|
|
c2cd2e1eb1 | ||
|
|
aa62de2e78 | ||
|
|
66713c5a1e | ||
|
|
b771794f1c | ||
|
|
6c63a17cfa | ||
|
|
ed66a2e873 | ||
|
|
cc73b38671 | ||
|
|
0993c31ad4 | ||
|
|
82475691b2 | ||
|
|
89046e6613 | ||
|
|
dc89995d96 | ||
|
|
6c10288f0b | ||
|
|
789b0a9474 | ||
|
|
eb97a3d9a2 | ||
|
|
8b2e2300d8 | ||
|
|
2f2daea415 | ||
|
|
3b48f5962a | ||
|
|
2b1678f315 | ||
|
|
ab739d02dd | ||
|
|
a60020b51a | ||
|
|
a8d84d1730 | ||
|
|
9e8521afb5 | ||
|
|
8c60604eb7 | ||
|
|
13cd710071 | ||
|
|
0914a33fba | ||
|
|
b5417c28d0 | ||
|
|
5f6a41146c | ||
|
|
c71c513027 | ||
|
|
2e32f47e45 | ||
|
|
9492278d93 | ||
|
|
5ce2d07c7f | ||
|
|
373f339750 | ||
|
|
48f0662fde | ||
|
|
7913b56d66 | ||
|
|
ab1811d003 | ||
|
|
c79337579d | ||
|
|
3a84044ece | ||
|
|
c51357f8af | ||
|
|
2833a049aa | ||
|
|
6f7f07635e | ||
|
|
0f76b6e232 | ||
|
|
3e16c055be | ||
|
|
0361c44a03 | ||
|
|
d1debdcce8 | ||
|
|
0e7d9e74d2 | ||
|
|
be53f5167a | ||
|
|
1d8a244ba4 | ||
|
|
2edef5a00a | ||
|
|
2130f99612 | ||
|
|
ed5690cee4 | ||
|
|
fadf5025cc | ||
|
|
baa5ef802e | ||
|
|
5b20e419ec | ||
|
|
7e3e62062b | ||
|
|
7e0001e304 | ||
|
|
d94764f001 | ||
|
|
482232e8c5 | ||
|
|
70c1110c53 | ||
|
|
e223262408 | ||
|
|
dbe2949f84 | ||
|
|
b7d9590fdc | ||
|
|
7ee2869d60 | ||
|
|
0b74c04aa8 | ||
|
|
e1c8965ddb | ||
|
|
47ef2644ac | ||
|
|
cb7bf94a77 | ||
|
|
8e821ef449 | ||
|
|
07b9bd0ed4 | ||
|
|
02427a60ef | ||
|
|
8661bcfa22 | ||
|
|
6049537655 | ||
|
|
bf3e801f67 | ||
|
|
50861c74f8 | ||
|
|
b15846dd6e | ||
|
|
a44d490d97 | ||
|
|
af5cf53a34 | ||
|
|
2bb5d344f6 | ||
|
|
e846c289c8 | ||
|
|
0fcc560fec | ||
|
|
acf8a76646 | ||
|
|
b62bb15839 | ||
|
|
ff9d3ee6a0 | ||
|
|
c8255eb15d | ||
|
|
4ded07bc63 | ||
|
|
054dc9618c | ||
|
|
ebf7f3773d | ||
|
|
c876ad4a83 | ||
|
|
12a5e7be73 | ||
|
|
23a2e1e204 | ||
|
|
66d22dd53e | ||
|
|
7ed119995f | ||
|
|
faebc3569a | ||
|
|
1faee6e279 | ||
|
|
6f7d06726c | ||
|
|
f09daabec3 | ||
|
|
bb5a342863 | ||
|
|
eadf301f9f | ||
|
|
9c166398f2 | ||
|
|
e209d46faf | ||
|
|
f1f866050e | ||
|
|
4d15a155e6 | ||
|
|
e46eeb8298 | ||
|
|
5f491e0c09 | ||
|
|
73404a3c3f | ||
|
|
305c2ba247 | ||
|
|
8ad121d5c3 | ||
|
|
305601d25e | ||
|
|
910bc58fe0 | ||
|
|
0096beeff9 | ||
|
|
6aa30eae66 | ||
|
|
bb60de6d61 | ||
|
|
a84d0094a0 | ||
|
|
dd39d4ace7 | ||
|
|
5bd40fa406 | ||
|
|
0d723484a8 | ||
|
|
8d8dcd5ebb | ||
|
|
cd23928320 | ||
|
|
8922c89059 | ||
|
|
789d2d9d64 | ||
|
|
1725ffc799 | ||
|
|
a72eb3dbb2 | ||
|
|
c0726ac264 | ||
|
|
19ecad261c | ||
|
|
c6a9c52dc7 | ||
|
|
45d01e77c2 | ||
|
|
c78d0a0134 | ||
|
|
f35b067504 | ||
|
|
93d58f3740 | ||
|
|
5cd608ba88 | ||
|
|
2f5e97030a | ||
|
|
bc3b59227f | ||
|
|
eb42806379 | ||
|
|
fe2040c65e | ||
|
|
53e5186496 | ||
|
|
e0bc919d49 | ||
|
|
f88e3452f8 | ||
|
|
827b89dbf1 | ||
|
|
c9a711c259 | ||
|
|
1835f0efad | ||
|
|
39e7a06e62 | ||
|
|
84911b94f3 | ||
|
|
1906081937 | ||
|
|
4193eacec4 | ||
|
|
8f80cd4716 | ||
|
|
43a9155d5a | ||
|
|
c4eebce598 | ||
|
|
14999f1d3e | ||
|
|
e4e0ce7619 | ||
|
|
9446bec2f9 | ||
|
|
9c9a76d220 | ||
|
|
ac2d4dc46f | ||
|
|
d09dbe33ac | ||
|
|
005f802504 | ||
|
|
eb5f33f2b4 | ||
|
|
c4b4bac757 | ||
|
|
0863ec2a05 | ||
|
|
854c85393c | ||
|
|
2fe5601397 | ||
|
|
01d2ff7a46 | ||
|
|
961e57f7e4 | ||
|
|
22071dabfc | ||
|
|
481e44cdaa | ||
|
|
23ea0f431f | ||
|
|
41b6e10077 | ||
|
|
2f55adc57c | ||
|
|
434506f9b1 | ||
|
|
82cca650a2 | ||
|
|
cd2b6aacd8 | ||
|
|
aed7e0c968 | ||
|
|
43df7ebc5a | ||
|
|
c5e1bc7bb4 | ||
|
|
6190b40175 | ||
|
|
edf39a3b74 | ||
|
|
15983e2f96 | ||
|
|
263c86d2e5 | ||
|
|
ec4ab7afe7 | ||
|
|
0e6f382bb7 | ||
|
|
d745a4d31e | ||
|
|
994cab533b | ||
|
|
2772120758 | ||
|
|
13a7d40ac5 | ||
|
|
a3ad3c5061 | ||
|
|
5b75499ea6 | ||
|
|
0366208274 | ||
|
|
9f893b6305 | ||
|
|
d1baca9b94 | ||
|
|
3a5c86b5d6 | ||
|
|
befe451f6d | ||
|
|
7ad54356fd | ||
|
|
69f76a53e7 | ||
|
|
b82aa1691c | ||
|
|
7e27259a64 | ||
|
|
3fe19c9c0e | ||
|
|
424fc5a281 | ||
|
|
f067fab9db | ||
|
|
56a9a1b082 | ||
|
|
df94be0e60 | ||
|
|
8f5e8bcb6e | ||
|
|
ca3c3e6dfd | ||
|
|
2051d8b80b | ||
|
|
3e84581927 | ||
|
|
4d9cb0827f | ||
|
|
ac90b35b49 | ||
|
|
f2d765320a | ||
|
|
4fdb047cc8 | ||
|
|
a92c08fd5b | ||
|
|
991ffcfda0 | ||
|
|
2927cb2ceb |
@@ -19,3 +19,10 @@ modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
|||||||
modules/mounts/cmd/mounts/mounts
|
modules/mounts/cmd/mounts/mounts
|
||||||
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||||
modules/mesh-knowledge/mesh-knowledge
|
modules/mesh-knowledge/mesh-knowledge
|
||||||
|
modules/mesh-reads/mesh-reads
|
||||||
|
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||||
|
modules/mesh-files/mesh-files
|
||||||
|
modules/mesh-files/cmd/mesh-files/mesh-files
|
||||||
|
|
||||||
|
# The provisioner client built in place (issue 671).
|
||||||
|
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Release 57: config and provisioning
|
||||||
|
|
||||||
|
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||||
|
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||||
|
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||||
|
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/config
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
|
||||||
|
// clauses 1 and 11, design 57 package K6, issue 665).
|
||||||
|
//
|
||||||
|
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
|
||||||
|
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
|
||||||
|
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
|
||||||
|
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
|
||||||
|
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
|
||||||
|
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
|
||||||
|
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
|
||||||
|
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
|
||||||
|
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
|
||||||
|
// while the trunk holds no copy, the modules in seed are the trunk's.
|
||||||
|
// When it is empty, the controller refuses the old names itself (design 57 package R1).
|
||||||
|
//
|
||||||
|
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
|
||||||
|
// rewording it would change what a node is sent.
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's unmoved>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
|
||||||
|
// string value is never followed by one.
|
||||||
|
var (
|
||||||
|
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
|
||||||
|
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
|
||||||
|
oldPlaceholder = "${setting:…}"
|
||||||
|
oldTakes = "takes-settings"
|
||||||
|
oldDeclaration = "settings"
|
||||||
|
wholeLayer = "taking its whole layer"
|
||||||
|
listFile = "unmoved"
|
||||||
|
listPathInCatalog = "checks/config/" + listFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
|
||||||
|
func Check(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
var said []string
|
||||||
|
if len(m.Settings) > 0 && string(m.Settings) != "null" {
|
||||||
|
said = append(said, oldDeclaration)
|
||||||
|
}
|
||||||
|
if takesSettingsKey.Match(raw) {
|
||||||
|
said = append(said, oldTakes)
|
||||||
|
}
|
||||||
|
if settingRef.Match(raw) {
|
||||||
|
said = append(said, oldPlaceholder)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
|
||||||
|
if how, _ := r["merge"].(string); how != "" {
|
||||||
|
_, oldList := r["takes-settings"]
|
||||||
|
_, newList := r["takes-config"]
|
||||||
|
if !oldList && !newList {
|
||||||
|
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
|
||||||
|
}
|
||||||
|
if settingRef.Match(text) {
|
||||||
|
said = append(said, oldPlaceholder+" in "+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) > 0 {
|
||||||
|
sort.Strings(said)
|
||||||
|
out[m.Module] = said
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||||
|
// 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,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||||
|
func Unmoved(path string) (map[string]bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[line] = true
|
||||||
|
}
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
|
||||||
|
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||||
|
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
|
||||||
|
for m := range found {
|
||||||
|
if !unmoved[m] {
|
||||||
|
refused = append(refused, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m := range unmoved {
|
||||||
|
if _, says := found[m]; !says {
|
||||||
|
stale = append(stale, m)
|
||||||
|
}
|
||||||
|
if !base[m] {
|
||||||
|
added = append(added, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(refused)
|
||||||
|
sort.Strings(stale)
|
||||||
|
sort.Strings(added)
|
||||||
|
return refused, stale, added
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
|
||||||
|
flag.Parse()
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
unmoved, err := Unmoved(listFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base := seed
|
||||||
|
if *basePath != "" {
|
||||||
|
if base, err = Unmoved(*basePath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, unmoved, base)
|
||||||
|
for _, m := range refused {
|
||||||
|
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
|
||||||
|
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
|
||||||
|
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
|
||||||
|
"(%d not yet moved)\n",
|
||||||
|
len(unmoved))
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func module(t *testing.T, root, name, body string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, text := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
|
||||||
|
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
|
||||||
|
func TestEveryOldNameIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
|
||||||
|
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
|
||||||
|
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
|
||||||
|
`"takes-settings":["a"],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
|
||||||
|
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
|
||||||
|
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
|
||||||
|
map[string]string{"files/conf": "size = ${setting:size}\n"})
|
||||||
|
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
|
||||||
|
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
|
||||||
|
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
|
||||||
|
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
|
||||||
|
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{
|
||||||
|
"asks": {"${setting:…}"},
|
||||||
|
"declares": {"${setting:…}", "settings"},
|
||||||
|
"takes": {"takes-settings"},
|
||||||
|
"templated": {"${setting:…} in files/conf"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
|
||||||
|
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
|
||||||
|
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
|
||||||
|
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
|
||||||
|
unmoved := map[string]bool{"old": true, "done": true, "new": true}
|
||||||
|
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||||
|
refused, stale, added := Judge(found, unmoved, base)
|
||||||
|
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||||
|
!reflect.DeepEqual(added, []string{"new"}) {
|
||||||
|
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "unmoved")
|
||||||
|
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Unmoved(path)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
|
||||||
|
// still say settings.
|
||||||
|
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
|
||||||
|
found, err := Check(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unmoved, err := Unmoved("unmoved")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, unmoved, seed)
|
||||||
|
for _, m := range refused {
|
||||||
|
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
|
||||||
|
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
|
||||||
|
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
|
||||||
|
// included, is not.
|
||||||
|
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
|
||||||
|
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
|
||||||
|
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
|
||||||
|
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
|
||||||
|
`"takes-config":[],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
|
||||||
|
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
|
||||||
|
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
|
||||||
|
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
|
||||||
|
# refuses the old names (design 57 package R1).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
|
||||||
|
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
|
||||||
|
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
|
||||||
|
//
|
||||||
|
// go run . <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailStoreSeat is the seat only mesh-mail may claim.
|
||||||
|
const MailStoreSeat = "mail-store"
|
||||||
|
|
||||||
|
// MailStoreModule is the one module allowed to claim it.
|
||||||
|
const MailStoreModule = "mesh-mail"
|
||||||
|
|
||||||
|
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
|
||||||
|
type Finding struct {
|
||||||
|
Module string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reads every manifest under root/modules and answers which ones it refuses.
|
||||||
|
func Check(root string) ([]Finding, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
var out []Finding
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Claims []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"claims"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if m.Module == MailStoreModule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.Name == MailStoreSeat {
|
||||||
|
out = append(out, Finding{Module: m.Module})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := "."
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
root = os.Args[1]
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("mail-store: claimed only by mesh-mail")
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func manifest(t *testing.T, root, module, claims string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", module)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := `{"module":"` + module + `","claims":[` + claims + `]}`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
|
||||||
|
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
|
||||||
|
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 || found[0].Module != "betterbird" {
|
||||||
|
t.Fatalf("refused %+v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCatalogueHasNone(t *testing.T) {
|
||||||
|
found, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 0 {
|
||||||
|
t.Fatalf("%+v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
||||||
|
// 0348 clause 7, design 57 package K2, issue 661).
|
||||||
|
//
|
||||||
|
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
||||||
|
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
||||||
|
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
||||||
|
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
||||||
|
// it, naming each module that names the key.
|
||||||
|
//
|
||||||
|
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
||||||
|
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
||||||
|
// itself, removing a key nobody names is let through here, and said.
|
||||||
|
//
|
||||||
|
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
||||||
|
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
||||||
|
// module check; this check reads only its keys.
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File is the list's file at the root of the catalogue.
|
||||||
|
const File = "predefined.json"
|
||||||
|
|
||||||
|
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
||||||
|
func Keys(path string) (map[string]bool, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var keys map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
||||||
|
}
|
||||||
|
out := map[string]bool{}
|
||||||
|
for k := range keys {
|
||||||
|
out[k] = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
||||||
|
func Named(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
named := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Config map[string]json.RawMessage `json:"config"`
|
||||||
|
Settings map[string]json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
||||||
|
for _, d := range declared {
|
||||||
|
var value struct {
|
||||||
|
Predefined string `json:"predefined"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
||||||
|
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return named, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
||||||
|
// key nobody names is answered with none.
|
||||||
|
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for k := range base {
|
||||||
|
if !tree[k] {
|
||||||
|
out[k] = named[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
||||||
|
flag.Parse()
|
||||||
|
if flag.NArg() != 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
root := flag.Arg(0)
|
||||||
|
tree, err := Keys(filepath.Join(root, File))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *basePath == "" {
|
||||||
|
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, err := Keys(*basePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
named, err := Named(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
removed := Removed(base, tree, named)
|
||||||
|
keys := make([]string, 0, len(removed))
|
||||||
|
for k := range removed {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
failed := false
|
||||||
|
for _, k := range keys {
|
||||||
|
if modules := removed[k]; len(modules) > 0 {
|
||||||
|
failed = true
|
||||||
|
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
||||||
|
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
||||||
|
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
||||||
|
// agent-managed-settings.
|
||||||
|
func aCatalogue(t *testing.T, list string) (root, base string) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
write := func(path, text string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
||||||
|
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
||||||
|
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
||||||
|
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
||||||
|
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
||||||
|
if list != "" {
|
||||||
|
write(filepath.Join(root, File), list)
|
||||||
|
}
|
||||||
|
base = filepath.Join(t.TempDir(), "base.json")
|
||||||
|
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
||||||
|
return root, base
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(t *testing.T, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
||||||
|
// The planted removal: font-size goes while dunst still falls back to it.
|
||||||
|
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
||||||
|
t.Fatalf("a removal of a named key passed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
||||||
|
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
||||||
|
!strings.Contains(out, "mesh-config's to refuse") {
|
||||||
|
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
||||||
|
for _, list := range []string{
|
||||||
|
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
||||||
|
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
||||||
|
} {
|
||||||
|
root, base := aCatalogue(t, list)
|
||||||
|
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
||||||
|
t.Fatalf("%s: %v\n%s", list, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No trunk list given: nothing was removed.
|
||||||
|
root, _ := aCatalogue(t, `{}`)
|
||||||
|
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
||||||
|
t.Fatalf("without a base: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
||||||
|
root, base := aCatalogue(t, "")
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
||||||
|
t.Fatalf("the list removed whole passed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamedReadsBothForms(t *testing.T) {
|
||||||
|
root, _ := aCatalogue(t, "")
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
||||||
|
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
named, err := Named(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
||||||
|
t.Fatalf("font-size named by %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Secrets nested below a key a mergeable file takes for its other values, which the controller cannot
|
||||||
|
# protect alone yet (it protects top-level keys): "<module> <resource> <dotted key>". This list only shrinks:
|
||||||
|
# a line that no longer names a finding is refused, and so is one the trunk's copy does not hold (hq issue 342).
|
||||||
|
searxng settings server.secret_key
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
|
||||||
|
// password, a token or an API key in place of the vault's (novox/hq issue 342).
|
||||||
|
//
|
||||||
|
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
|
||||||
|
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
|
||||||
|
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
|
||||||
|
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
||||||
|
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
||||||
|
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
||||||
|
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
|
||||||
|
// in files/ is judged by the template's text.
|
||||||
|
//
|
||||||
|
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
||||||
|
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
||||||
|
// beside this check, one line per key, until the controller refuses a nested key. The list only shrinks: a
|
||||||
|
// line there that no longer names a finding is refused, and so is a line the trunk's copy (the merge base's,
|
||||||
|
// given with -base) does not hold. While the trunk holds no copy, the lines in seed are the trunk's.
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's known>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one key of one mergeable file a layer could fill in place of the vault.
|
||||||
|
type Finding struct {
|
||||||
|
Module, ID, Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
|
||||||
|
|
||||||
|
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
|
||||||
|
func Check(root string) ([]Finding, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
var out []Finding
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if how, _ := r["merge"].(string); how == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := r["id"].(string)
|
||||||
|
content, _ := r["content"].(string)
|
||||||
|
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
|
||||||
|
// exactly as it would be inline.
|
||||||
|
if name, _ := r["template"].(string); name != "" {
|
||||||
|
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
|
||||||
|
}
|
||||||
|
content = string(text)
|
||||||
|
}
|
||||||
|
var doc map[string]any
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
|
||||||
|
}
|
||||||
|
protected := set(r["protected"])
|
||||||
|
takes, names := r["takes-settings"]
|
||||||
|
taken := set(takes)
|
||||||
|
for _, key := range sortedKeys(doc) {
|
||||||
|
for _, at := range secretsBelow(key, doc[key]) {
|
||||||
|
switch {
|
||||||
|
case protected[key]:
|
||||||
|
case names && !taken[key]:
|
||||||
|
default:
|
||||||
|
out = append(out, Finding{m.Module, id, at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
|
||||||
|
func secretsBelow(at string, value any) []string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.Contains(v, "${secret:") {
|
||||||
|
return []string{at}
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
var out []string
|
||||||
|
for _, k := range sortedKeys(v) {
|
||||||
|
out = append(out, secretsBelow(at+"."+k, v[k])...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
var out []string
|
||||||
|
for i, e := range v {
|
||||||
|
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(v any) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
list, _ := v.([]any)
|
||||||
|
for _, e := range list {
|
||||||
|
if s, ok := e.(string); ok {
|
||||||
|
out[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
|
||||||
|
func Known(path string) (map[string]bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[strings.Join(strings.Fields(line), " ")] = true
|
||||||
|
}
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed is the known list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||||
|
var seed = map[string]bool{
|
||||||
|
"searxng settings server.secret_key": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added answers the lines of known that base does not hold: a line may leave the list, never join it.
|
||||||
|
func Added(known, base map[string]bool) []string {
|
||||||
|
var out []string
|
||||||
|
for line := range known {
|
||||||
|
if !base[line] {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge splits the findings into those refused and the known lines that no longer name one.
|
||||||
|
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, f := range found {
|
||||||
|
seen[f.String()] = true
|
||||||
|
if !known[f.String()] {
|
||||||
|
refused = append(refused, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line := range known {
|
||||||
|
if !seen[line] {
|
||||||
|
stale = append(stale, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(stale)
|
||||||
|
return refused, stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// remedy says how a finding is put right.
|
||||||
|
func remedy(key string) string {
|
||||||
|
top, _, nested := strings.Cut(key, ".")
|
||||||
|
if !nested {
|
||||||
|
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
|
||||||
|
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's copy of known (the merge base's); seed when absent")
|
||||||
|
flag.Parse()
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
known, err := Known("known")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base := seed
|
||||||
|
if *basePath != "" {
|
||||||
|
if base, err = Known(*basePath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added := Added(known, base)
|
||||||
|
for _, line := range added {
|
||||||
|
fmt.Printf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — "+
|
||||||
|
"protect the key instead (novox/hq issue 342)\n", line)
|
||||||
|
}
|
||||||
|
refused, stale := Judge(found, known)
|
||||||
|
for _, f := range refused {
|
||||||
|
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
|
||||||
|
f.Module, f.ID, f.Key, remedy(f.Key))
|
||||||
|
}
|
||||||
|
for _, line := range stale {
|
||||||
|
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
|
||||||
|
"remove the line\n", line)
|
||||||
|
}
|
||||||
|
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func manifest(t *testing.T, root, module, resources string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", module)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
// Refused: the key holds the vault's secret and nothing keeps a layer off it.
|
||||||
|
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||||
|
// Refused: nested below a key the file takes.
|
||||||
|
manifest(t, root, "b", `{"id":"cfg","type":"file","merge":"json","takes-settings":["server"],`+
|
||||||
|
`"content":"{\"server\": {\"secret_key\": \"${secret:s}\", \"port\": 1}}"}`)
|
||||||
|
// Kept: protected.
|
||||||
|
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],`+
|
||||||
|
`"content":"{\"password\": \"${secret:admin}\", \"user\": \"admin\"}"}`)
|
||||||
|
// Kept: the file names its keys, and not this one.
|
||||||
|
manifest(t, root, "d", `{"id":"cfg","type":"file","merge":"json","takes-settings":["ui"],`+
|
||||||
|
`"content":"{\"token\": \"${secret:t}\", \"ui\": {}}"}`)
|
||||||
|
// Kept: a file that does not merge takes no setting.
|
||||||
|
manifest(t, root, "e", `{"id":"cfg","type":"file","content":"{\"token\": \"${secret:t}\"}"}`)
|
||||||
|
// Refused: protecting another key does not protect this one; a list element counts too.
|
||||||
|
manifest(t, root, "f", `{"id":"cfg","type":"file","merge":"json","protected":["user"],`+
|
||||||
|
`"content":"{\"users\": [{\"password\": \"${secret:p}\"}]}"}`)
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"a cfg password", "b cfg server.secret_key", "f cfg users[0].password"}
|
||||||
|
if len(found) != len(want) {
|
||||||
|
t.Fatalf("found %v, want %v", found, want)
|
||||||
|
}
|
||||||
|
for i, f := range found {
|
||||||
|
if f.String() != want[i] {
|
||||||
|
t.Fatalf("found %v, want %v", found, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
|
||||||
|
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
|
||||||
|
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
|
||||||
|
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
|
||||||
|
for _, m := range []string{"a", "c"} {
|
||||||
|
dir := filepath.Join(root, "modules", m, "files")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 || found[0].String() != "a cfg password" {
|
||||||
|
t.Fatalf("found %v, want [a cfg password]", found)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
|
||||||
|
if _, err := Check(root); err == nil {
|
||||||
|
t.Fatal("a template the module's directory lacks passed the check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
||||||
|
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
||||||
|
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
||||||
|
if len(refused) != 1 || refused[0].String() != "a cfg password" {
|
||||||
|
t.Fatalf("refused %v", refused)
|
||||||
|
}
|
||||||
|
if len(stale) != 1 || stale[0] != "gone cfg token" {
|
||||||
|
t.Fatalf("stale %v", stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCatalogueHoldsNoSecretASettingCouldReplace(t *testing.T) {
|
||||||
|
found, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
known, err := Known("known")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale := Judge(found, known)
|
||||||
|
if len(refused) > 0 || len(stale) > 0 {
|
||||||
|
t.Fatalf("refused %v, stale %v", refused, stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALineTheTrunkDoesNotHoldIsRefused(t *testing.T) {
|
||||||
|
trunk := map[string]bool{"b cfg server.secret_key": true}
|
||||||
|
// A key unprotected on the branch, and its line added to the list: refused, though it matches a finding.
|
||||||
|
branch := map[string]bool{"b cfg server.secret_key": true, "a cfg password": true}
|
||||||
|
if added := Added(branch, trunk); len(added) != 1 || added[0] != "a cfg password" {
|
||||||
|
t.Fatalf("added %v", added)
|
||||||
|
}
|
||||||
|
// A line removed is no addition.
|
||||||
|
if added := Added(map[string]bool{}, trunk); len(added) != 0 {
|
||||||
|
t.Fatalf("added %v", added)
|
||||||
|
}
|
||||||
|
// Until the trunk holds a copy, the seed stands for it, and the list as committed is within it.
|
||||||
|
known, err := Known("known")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if added := Added(known, seed); len(added) != 0 {
|
||||||
|
t.Fatalf("known holds lines the seed does not: %v", added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCheckRefusesALineAddedAgainstTheTrunk(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||||
|
dir := t.TempDir()
|
||||||
|
base := filepath.Join(dir, "base")
|
||||||
|
if err := os.WriteFile(base, []byte("# the trunk's\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
branch := map[string]bool{"a cfg password": true}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trunk, err := Known(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale := Judge(found, branch)
|
||||||
|
if len(refused) != 0 || len(stale) != 0 {
|
||||||
|
t.Fatalf("the line hides the finding from Judge alone: %v %v", refused, stale)
|
||||||
|
}
|
||||||
|
if added := Added(branch, trunk); len(added) != 1 {
|
||||||
|
t.Fatalf("an added line passed: %v", added)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
|
||||||
|
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
|
||||||
|
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
|
||||||
|
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
|
||||||
|
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
|
||||||
|
# issue 658).
|
||||||
|
#
|
||||||
|
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
|
||||||
|
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
|
||||||
|
// ADR 0349 §2, design 57 package T5, issue 658).
|
||||||
|
//
|
||||||
|
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
|
||||||
|
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
|
||||||
|
// archive; the controller reads each template back into the file's content, so the
|
||||||
|
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
|
||||||
|
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
|
||||||
|
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
|
||||||
|
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
|
||||||
|
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
|
||||||
|
//
|
||||||
|
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
|
||||||
|
// collections (design 57 package L2, issue 681).
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's inline>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
listFile = "inline"
|
||||||
|
listPathInCatalog = "checks/templates/" + listFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
|
||||||
|
func Check(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline && r["type"] == "file" {
|
||||||
|
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
|
||||||
|
// `templates`, files/ when the manifest says none or cannot be read here.
|
||||||
|
func TemplatesDir(root, module string) string {
|
||||||
|
dir := "files/"
|
||||||
|
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Templates string `json:"templates"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
|
||||||
|
dir = strings.TrimSuffix(m.Templates, "/") + "/"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refusal is the finding for a module that keeps file text inline and is not on the list.
|
||||||
|
func Refusal(module string, ids []string, dir string) string {
|
||||||
|
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
|
||||||
|
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
|
||||||
|
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gained is the finding for a module the list gains against the trunk's copy.
|
||||||
|
func Gained(module, dir string) string {
|
||||||
|
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
|
||||||
|
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||||
|
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
|
||||||
|
|
||||||
|
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||||
|
func Listed(path string) (map[string]bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[line] = true
|
||||||
|
}
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
|
||||||
|
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||||
|
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
|
||||||
|
for m := range found {
|
||||||
|
if !listed[m] {
|
||||||
|
refused = append(refused, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m := range listed {
|
||||||
|
if _, keeps := found[m]; !keeps {
|
||||||
|
stale = append(stale, m)
|
||||||
|
}
|
||||||
|
if !base[m] {
|
||||||
|
added = append(added, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(refused)
|
||||||
|
sort.Strings(stale)
|
||||||
|
sort.Strings(added)
|
||||||
|
return refused, stale, added
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
|
||||||
|
flag.Parse()
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
listed, err := Listed(listFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base := seed
|
||||||
|
if *basePath != "" {
|
||||||
|
if base, err = Listed(*basePath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, listed, base)
|
||||||
|
for _, m := range refused {
|
||||||
|
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
fmt.Println(Gained(m, TemplatesDir(root, m)))
|
||||||
|
}
|
||||||
|
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func module(t *testing.T, root, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file resource that says its text inline is found, by module and resource; one naming its template, a
|
||||||
|
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryInlineFileTextIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
|
||||||
|
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
|
||||||
|
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
|
||||||
|
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{"inline": {"a", "c"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
|
||||||
|
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
|
||||||
|
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
|
||||||
|
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
|
||||||
|
inline := map[string]bool{"old": true, "done": true, "new": true}
|
||||||
|
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||||
|
refused, stale, added := Judge(found, inline, base)
|
||||||
|
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||||
|
!reflect.DeepEqual(added, []string{"new"}) {
|
||||||
|
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "inline")
|
||||||
|
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Listed(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
|
||||||
|
t.Fatalf("read %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
|
||||||
|
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
|
||||||
|
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
|
||||||
|
found, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inline, err := Listed(listFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale, _ := Judge(found, inline, inline)
|
||||||
|
if len(refused) > 0 || len(stale) > 0 {
|
||||||
|
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
|
||||||
|
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||||
|
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||||
|
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
|
||||||
|
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
|
||||||
|
if got := TemplatesDir(root, module); got != dir {
|
||||||
|
t.Fatalf("%s: directory %q, want %q", module, got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
|
||||||
|
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||||
|
t.Fatalf("refusal %q", msg)
|
||||||
|
}
|
||||||
|
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||||
|
t.Fatalf("gained %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
69
|
61
|
||||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||||
|
|||||||
@@ -23,9 +23,51 @@
|
|||||||
#
|
#
|
||||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
||||||
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
||||||
|
#
|
||||||
|
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
|
||||||
|
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
|
||||||
|
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
|
||||||
|
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
|
||||||
|
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
|
||||||
|
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
|
||||||
|
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
|
||||||
|
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
|
||||||
|
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
||||||
|
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
|
||||||
|
origin=$(git remote get-url origin 2>/dev/null || true)
|
||||||
|
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
|
||||||
|
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
|
||||||
|
if [ -z "$forge" ] || [ -z "$owner" ]; then
|
||||||
|
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
|
||||||
|
tc="$work/toolchain"
|
||||||
|
mkdir -p "$tc"
|
||||||
|
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
|
||||||
|
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
|
||||||
|
if ! { node -e '
|
||||||
|
const [base, dir] = process.argv.slice(1);
|
||||||
|
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
|
||||||
|
const r = await fetch(base + f);
|
||||||
|
if (!r.ok) throw new Error(base + f + ": " + r.status);
|
||||||
|
require("fs").writeFileSync(dir + "/" + f, await r.text());
|
||||||
|
})).catch((e) => { console.error(e.message); process.exit(1); });' \
|
||||||
|
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
|
||||||
|
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
|
||||||
|
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
|
||||||
|
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
|
||||||
|
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
deps="$tc/node_modules"
|
||||||
|
fi
|
||||||
tsc="$deps/typescript/bin/tsc"
|
tsc="$deps/typescript/bin/tsc"
|
||||||
if [ ! -f "$tsc" ]; then
|
if [ ! -f "$tsc" ]; then
|
||||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||||
@@ -39,8 +81,6 @@ touched=$({
|
|||||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||||
} | sed '/^$/d' | sort -u)
|
} | sed '/^$/d' | sort -u)
|
||||||
|
|
||||||
work=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$work"' EXIT
|
|
||||||
failed=""
|
failed=""
|
||||||
for m in $touched; do
|
for m in $touched; do
|
||||||
d="modules/$m"
|
d="modules/$m"
|
||||||
|
|||||||
@@ -30,11 +30,77 @@
|
|||||||
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
||||||
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
||||||
# that home and could swap a link in between.
|
# that home and could swap a link in between.
|
||||||
|
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||||
|
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||||
|
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||||
|
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
|
||||||
|
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
|
||||||
|
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
|
||||||
|
# every manifest against it are judged by the controller's module check in step 1.
|
||||||
|
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
|
||||||
|
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
|
||||||
|
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
|
||||||
|
# judged against the trunk's copy at the merge base.
|
||||||
|
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
|
||||||
|
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
|
||||||
|
# only shrinks, judged against the trunk's copy at the merge base.
|
||||||
|
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||||
|
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||||
|
(cd checks/mailstore && go test -count=1 . && go run . ../..)
|
||||||
|
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
||||||
|
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||||
|
secrets_base=""
|
||||||
|
secrets_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||||
|
if merge_base=$(git merge-base HEAD "$secrets_ref" 2>/dev/null); then
|
||||||
|
if git cat-file -e "$merge_base:checks/secrets/known" 2>/dev/null; then
|
||||||
|
secrets_base=$(mktemp)
|
||||||
|
git show "$merge_base:checks/secrets/known" >"$secrets_base"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
||||||
|
fi
|
||||||
|
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
||||||
|
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
|
||||||
|
# list at the merge base is what it is judged against; a trunk without the file had none.
|
||||||
|
predefined_base=""
|
||||||
|
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||||
|
predefined_base=$(mktemp)
|
||||||
|
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
|
||||||
|
echo '{}' >"$predefined_base"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
|
||||||
|
fi
|
||||||
|
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
|
||||||
|
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
|
||||||
|
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
|
||||||
|
unmoved_base=""
|
||||||
|
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||||
|
if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then
|
||||||
|
unmoved_base=$(mktemp)
|
||||||
|
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
|
||||||
|
fi
|
||||||
|
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
|
||||||
|
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
|
||||||
|
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
|
||||||
|
inline_base=""
|
||||||
|
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||||
|
if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then
|
||||||
|
inline_base=$(mktemp)
|
||||||
|
git show "$merge_base:checks/templates/inline" >"$inline_base"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
|
||||||
|
fi
|
||||||
|
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi)
|
||||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||||
base_lists=""
|
base_lists=""
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
|
|||||||
|
|
||||||
| path | class | from |
|
| path | class | from |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
||||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
||||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
||||||
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||||
|
|
||||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
||||||
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
||||||
|
|||||||
@@ -18,10 +18,18 @@ type manifest struct {
|
|||||||
Environment struct {
|
Environment struct {
|
||||||
Variables map[string]string `json:"variables"`
|
Variables map[string]string `json:"variables"`
|
||||||
} `json:"environment"`
|
} `json:"environment"`
|
||||||
|
Items []struct {
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
Attributes map[string]string `json:"attributes"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
} `json:"items"`
|
||||||
Shell []struct {
|
Shell []struct {
|
||||||
For, Slot, Code string
|
For, Slot, Code string
|
||||||
} `json:"shell"`
|
} `json:"shell"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManifest(t *testing.T) manifest {
|
func readManifest(t *testing.T) manifest {
|
||||||
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
|
|||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
|
||||||
|
for _, it := range m.Items {
|
||||||
|
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(it.Collection, ":")
|
||||||
|
switch {
|
||||||
|
case len(parts) == 3 && parts[0] == "shell":
|
||||||
|
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
|
||||||
|
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
|
||||||
|
if m.Environment.Variables == nil {
|
||||||
|
m.Environment.Variables = map[string]string{}
|
||||||
|
}
|
||||||
|
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
pkgs = append(pkgs, r["package"].(string))
|
pkgs = append(pkgs, r["package"].(string))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
_, inline := r["content"]
|
||||||
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
||||||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||||
|
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
||||||
}
|
}
|
||||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
if strings.Contains(r["path"].(string), "qt5ct") {
|
||||||
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
||||||
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
||||||
t.Fatalf("%v", pkgs)
|
t.Fatalf("%v", pkgs)
|
||||||
}
|
}
|
||||||
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
|
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
||||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
||||||
t.Fatalf("%v", qt)
|
t.Fatalf("%v", qt)
|
||||||
}
|
}
|
||||||
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
||||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
||||||
t.Fatalf("%v", gtk)
|
t.Fatalf("%v", gtk)
|
||||||
}
|
}
|
||||||
@@ -244,3 +270,31 @@ func asJSON(v any) string {
|
|||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
m := readManifest(t)
|
||||||
|
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 name, ok := r["template"].(string); ok {
|
||||||
|
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
|
||||||
|
t.Errorf("resource %v names %s: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Adwaita:dark
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
qt6ct
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
6
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fusion
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Adwaita
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
|
||||||
|
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
|
||||||
|
# module's default; adwaita_appearance switches it for a session.
|
||||||
|
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
|
||||||
|
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface cursor-size 24 || true
|
||||||
|
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
|
||||||
|
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
! adwaita: the cursor, for X programs that take it from the resources.
|
||||||
|
Xcursor.theme: Adwaita
|
||||||
|
Xcursor.size: 24
|
||||||
+81
-23
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"module": "adwaita",
|
"module": "adwaita",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"package-manager"
|
||||||
],
|
],
|
||||||
@@ -10,27 +11,79 @@
|
|||||||
"adwaita_icons",
|
"adwaita_icons",
|
||||||
"adwaita_portal_check"
|
"adwaita_portal_check"
|
||||||
],
|
],
|
||||||
"environment": {
|
"items": [
|
||||||
"variables": {
|
|
||||||
"GTK_THEME": "Adwaita:dark",
|
|
||||||
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
|
|
||||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
|
||||||
"QT_STYLE_OVERRIDE": "Fusion",
|
|
||||||
"QT_SELECT": "6",
|
|
||||||
"XCURSOR_THEME": "Adwaita",
|
|
||||||
"XCURSOR_SIZE": "24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"shell": [
|
|
||||||
{
|
{
|
||||||
"for": "xresources",
|
"collection": "environment",
|
||||||
"slot": "normal",
|
"name": "gtk-theme",
|
||||||
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "GTK_THEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-gtk-theme"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"for": "xinitrc",
|
"collection": "environment",
|
||||||
"slot": "normal",
|
"name": "gtk2-rc-files",
|
||||||
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "GTK2_RC_FILES"
|
||||||
|
},
|
||||||
|
"template": "files/env-gtk2-rc-files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-qpa-platformtheme",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_QPA_PLATFORMTHEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-qpa-platformtheme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-style-override",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_STYLE_OVERRIDE"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-style-override"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-select",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_SELECT"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-select"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "xcursor-theme",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "XCURSOR_THEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-xcursor-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "xcursor-size",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "XCURSOR_SIZE"
|
||||||
|
},
|
||||||
|
"template": "files/env-xcursor-size"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "shell:xresources:normal",
|
||||||
|
"name": "xresources-normal",
|
||||||
|
"template": "files/xresources-normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "shell:xinitrc:normal",
|
||||||
|
"name": "xinitrc-normal",
|
||||||
|
"template": "files/xinitrc-normal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -65,7 +118,7 @@
|
|||||||
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
"template": "files/gtk-settings.ini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gtk4",
|
"id": "gtk4",
|
||||||
@@ -73,7 +126,7 @@
|
|||||||
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
"template": "files/gtk-settings.ini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "qt6ct",
|
"id": "qt6ct",
|
||||||
@@ -81,7 +134,7 @@
|
|||||||
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
|
"template": "files/qt6ct.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "portals",
|
"id": "portals",
|
||||||
@@ -89,7 +142,7 @@
|
|||||||
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
|
"template": "files/portals.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cursor",
|
"id": "cursor",
|
||||||
@@ -97,7 +150,7 @@
|
|||||||
"path": "${machine:account-home}/.icons/default/index.theme",
|
"path": "${machine:account-home}/.icons/default/index.theme",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
|
"template": "files/cursor-index.theme"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -112,6 +165,11 @@
|
|||||||
"loads": [
|
"loads": [
|
||||||
"adwaita-tools"
|
"adwaita-tools"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# artifact-store-tools
|
# artifact-store-tools
|
||||||
|
|
||||||
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
|
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
|
||||||
(novox/hq ADR 0251 §1–3, to-be 51). It declares no resources and changes nothing on its machine.
|
(novox/hq ADR 0251 §1–3, to-be 51). It changes nothing on its machine; its only resources are its state
|
||||||
|
directory and the file the mesh writes there from its setting `older-store-names`.
|
||||||
|
|
||||||
## Why a module beside the store
|
## Why a module beside the store
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ on that machine.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
|
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
|
||||||
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
|
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
|
||||||
| `artifact_store_references` | r | what the controller's records say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
| `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
||||||
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
|
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
|
||||||
|
|
||||||
### How the store is read
|
### How the store is read
|
||||||
@@ -43,6 +44,7 @@ it lists. The store's collector removes every blob no manifest marks, so these n
|
|||||||
| state | what | removable |
|
| state | what | removable |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
|
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
|
||||||
|
| `named-by-a-definition` | no record names it, and a definition in the catalogue pins it by digest, under the store's current name or an older one; `why` names the module (hq issue 323) | no |
|
||||||
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
|
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
|
||||||
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
|
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
|
||||||
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
|
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
|
||||||
@@ -56,6 +58,20 @@ its own right; the controller lets an index go with the platform manifests only
|
|||||||
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
|
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
|
||||||
record names leaves its platform manifests unrecorded with it.
|
record names leaves its platform manifests unrecorded with it.
|
||||||
|
|
||||||
|
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
|
||||||
|
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
|
||||||
|
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
|
||||||
|
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
|
||||||
|
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
|
||||||
|
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
|
||||||
|
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
|
||||||
|
default, set with the controller's `settings`), which the mesh writes to the module's `store-names` file: a
|
||||||
|
catalogue names no installation. When they cannot be read, the answer's first line says so. A manifest no record
|
||||||
|
names that a definition pins is `named-by-a-definition`; one the records name keeps the records' state. A
|
||||||
|
definition read at its commit is kept, so the next call asks only for the list. Definitions that could
|
||||||
|
not be read are listed under `definitions.unread`, and the answer's first line says that what they pin
|
||||||
|
reads as unrecorded until they are.
|
||||||
|
|
||||||
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
|
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
|
||||||
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
|
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
|
||||||
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
|
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
|
||||||
@@ -88,6 +104,8 @@ with slashes, a cut listing refused), manifests read with one absent or unreadab
|
|||||||
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
|
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
|
||||||
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
|
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
|
||||||
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
|
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
|
||||||
the fall-back when the references let go of cannot be carried, escalation through `sudo -n`, and that the
|
the fall-back when the references let go of cannot be carried, a manifest a definition pins under the
|
||||||
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs.
|
store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the
|
||||||
|
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
|
||||||
|
catalogue's two tools.
|
||||||
Any command other than the two read-only scripts fails the test.
|
Any command other than the two read-only scripts fails the test.
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import (
|
|||||||
|
|
||||||
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
|
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
|
||||||
const (
|
const (
|
||||||
StateKept = "kept"
|
StateKept = "kept"
|
||||||
StateHolderKept = "holder-of-kept-archive"
|
StateHolderKept = "holder-of-kept-archive"
|
||||||
StateEligible = "eligible"
|
StateEligible = "eligible"
|
||||||
StateHolderEligible = "holder-of-eligible-archive"
|
StateHolderEligible = "holder-of-eligible-archive"
|
||||||
StateCollectedPresent = "let-go-yet-present"
|
StateCollectedPresent = "let-go-yet-present"
|
||||||
StateNamedDocument = "named-document"
|
StateNamedDocument = "named-document"
|
||||||
|
// StateNamedByDefinition is a manifest no record names that a definition pins by digest, under the
|
||||||
|
// store's current name or an older one (novox/hq issue 323): a machine may run it, so it is kept.
|
||||||
|
StateNamedByDefinition = "named-by-a-definition"
|
||||||
StateUnrecorded = "unrecorded"
|
StateUnrecorded = "unrecorded"
|
||||||
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
|
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
|
||||||
)
|
)
|
||||||
@@ -45,8 +48,8 @@ var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z
|
|||||||
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
|
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
|
||||||
// wins, so a platform two indexes name is as kept as the more kept of them.
|
// wins, so a platform two indexes name is as kept as the more kept of them.
|
||||||
var keeping = map[string]int{
|
var keeping = map[string]int{
|
||||||
StateKept: 0, StateNamedDocument: 1, StateHolderKept: 2, StateEligible: 3, StateHolderEligible: 4,
|
StateKept: 0, StateNamedByDefinition: 1, StateNamedDocument: 2, StateHolderKept: 3, StateEligible: 4,
|
||||||
StateCollectedPresent: 5,
|
StateHolderEligible: 5, StateCollectedPresent: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordIndex finds a record by repository and digest.
|
// recordIndex finds a record by repository and digest.
|
||||||
@@ -64,8 +67,9 @@ func indexRecords(r *Records) recordIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify gives every manifest the store holds one state.
|
// Classify gives every manifest the store holds one state. A manifest no record names that a definition
|
||||||
func Classify(v *View, recs *Records) []Entry {
|
// pins is named by a definition; defs may be nil, when the definitions were not read.
|
||||||
|
func Classify(v *View, recs *Records, defs *Definitions) []Entry {
|
||||||
idx := indexRecords(recs)
|
idx := indexRecords(recs)
|
||||||
var out []Entry
|
var out []Entry
|
||||||
for _, name := range v.L.RepoNames() {
|
for _, name := range v.L.RepoNames() {
|
||||||
@@ -99,6 +103,12 @@ func Classify(v *View, recs *Records) []Entry {
|
|||||||
e.State = StateNamedDocument
|
e.State = StateNamedDocument
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if e.State == StateUnrecorded && defs != nil {
|
||||||
|
for _, p := range defs.Pins[Key(name, d)] {
|
||||||
|
e.State = StateNamedByDefinition
|
||||||
|
e.Why = append(e.Why, p.why())
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the definitions pin in the store (novox/hq issue 323). A definition may pin an image of the store by
|
||||||
|
// digest that the mesh did not make: one pushed before the mesh built it, pinned under the store's current
|
||||||
|
// name or under a name the store had before. No record of the controller names such a manifest, and yet a
|
||||||
|
// machine runs it, so it is said to be named by a definition and never unrecorded.
|
||||||
|
|
||||||
|
// NamesFileEnv names the file the mesh writes from the setting older-store-names: a line `older=` with the
|
||||||
|
// names the store had before, as a definition may still write them — host names, or host:port, separated by
|
||||||
|
// spaces — or `none`. A catalogue names no installation, so the names are the mesh's setting, not the
|
||||||
|
// manifest's.
|
||||||
|
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||||
|
|
||||||
|
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||||
|
const (
|
||||||
|
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||||
|
CatalogueModule = "mesh-catalog.catalog_module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
|
||||||
|
// was read is kept, so a call that runs out reads only the rest the next time.
|
||||||
|
const DefinitionsTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// definitionReaders is how many definitions are asked at once.
|
||||||
|
const definitionReaders = 8
|
||||||
|
|
||||||
|
// StoreNames are the names a definition may pin an image of the store under: the scheme the mesh writes
|
||||||
|
// (artifact-store://), the store's address now, and the names it had before.
|
||||||
|
type StoreNames struct {
|
||||||
|
Current []string
|
||||||
|
Older []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamesOf is the store's names: its address as the controller answers it, and the older ones given.
|
||||||
|
func NamesOf(address, older string) StoreNames {
|
||||||
|
n := StoreNames{Current: []string{"artifact-store://"}}
|
||||||
|
if a := strings.Trim(strings.TrimSpace(address), "/"); a != "" {
|
||||||
|
n.Current = append(n.Current, a+"/")
|
||||||
|
}
|
||||||
|
for _, o := range strings.FieldsFunc(older, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) {
|
||||||
|
if o = strings.Trim(o, "/"); o != "" && o != "none" {
|
||||||
|
n.Older = append(n.Older, o+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// OlderNames reads the older names from the file the mesh writes, and says why when it cannot.
|
||||||
|
func OlderNames() (string, string) {
|
||||||
|
path := os.Getenv(NamesFileEnv)
|
||||||
|
if path == "" {
|
||||||
|
return "", NamesFileEnv + " is not set, so no older name of the store was read"
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "the store's older names could not be read: " + err.Error()
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "older="); ok {
|
||||||
|
return v, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", path + " holds no line older=, so no older name of the store was read"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin is one definition pinning a manifest of the store by digest.
|
||||||
|
type Pin struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
// Name is the store's name it is pinned under, as written: "artifact-store://", an address, or an older name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
Older bool `json:"older,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinned is one pin found in a manifest: where in the store, and under which name.
|
||||||
|
type pinned struct {
|
||||||
|
Repository, Digest, Name string
|
||||||
|
Older bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinShapes sync.Map // name -> *regexp.Regexp
|
||||||
|
|
||||||
|
// pinShape is `<name><repository>@sha256:<hex>`, the name standing on its own: not the tail of a longer host.
|
||||||
|
func pinShape(name string) *regexp.Regexp {
|
||||||
|
if re, ok := pinShapes.Load(name); ok {
|
||||||
|
return re.(*regexp.Regexp)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?:^|[^A-Za-z0-9.-])` + regexp.QuoteMeta(name) +
|
||||||
|
`([a-z0-9][a-z0-9._/-]*)@(sha256:[0-9a-f]{64})`)
|
||||||
|
pinShapes.Store(name, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinsIn finds every image of the store a manifest pins by digest, in any string it holds: a resource's
|
||||||
|
// image, a build's base, a file's content.
|
||||||
|
func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||||
|
var doc any
|
||||||
|
if json.Unmarshal(manifest, &doc) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []pinned
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(x any) {
|
||||||
|
switch v := x.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for _, e := range v {
|
||||||
|
walk(e)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, e := range v {
|
||||||
|
walk(e)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
for _, group := range []struct {
|
||||||
|
names []string
|
||||||
|
older bool
|
||||||
|
}{{names.Current, false}, {names.Older, true}} {
|
||||||
|
for _, name := range group.names {
|
||||||
|
for _, m := range pinShape(name).FindAllStringSubmatch(v, -1) {
|
||||||
|
p := pinned{Repository: strings.Trim(m[1], "/"), Digest: m[2], Name: name, Older: group.older}
|
||||||
|
if k := p.Repository + "@" + p.Digest + " " + p.Name; !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||||
|
type Definitions struct {
|
||||||
|
// Pins are by Key(repository, digest).
|
||||||
|
Pins map[string][]Pin
|
||||||
|
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||||
|
Read int
|
||||||
|
Unread map[string]string
|
||||||
|
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||||
|
NotAsked string
|
||||||
|
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||||
|
NamesNotRead string
|
||||||
|
Names StoreNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||||
|
// module and commit, which never change together.
|
||||||
|
type Catalogue struct {
|
||||||
|
Ask Asking
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
kept map[string][]pinned // "<module>@<commit>|<names>" -> its pins
|
||||||
|
}
|
||||||
|
|
||||||
|
type catalogueEntry struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||||
|
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||||
|
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||||
|
if c == nil || c.Ask == nil {
|
||||||
|
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
answer, output, ok := answerOf(raw)
|
||||||
|
if !ok {
|
||||||
|
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Modules []catalogueEntry `json:"modules"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||||
|
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.kept == nil {
|
||||||
|
c.kept = map[string][]pinned{}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
nameKey := strings.Join(names.Current, ",") + ";" + strings.Join(names.Older, ",")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefinitionsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
type result struct {
|
||||||
|
module string
|
||||||
|
pins []pinned
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
results := make(chan result, len(list.Modules))
|
||||||
|
work := make(chan catalogueEntry)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < definitionReaders; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for m := range work {
|
||||||
|
pins, why := c.definition(m, names, nameKey)
|
||||||
|
results <- result{m.Module, pins, why}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer close(work)
|
||||||
|
for _, m := range list.Modules {
|
||||||
|
select {
|
||||||
|
case work <- m:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() { wg.Wait(); close(results) }()
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
collect:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case r, open := <-results:
|
||||||
|
if !open {
|
||||||
|
break collect
|
||||||
|
}
|
||||||
|
got[r.module] = true
|
||||||
|
if r.why != "" {
|
||||||
|
defs.Unread[r.module] = r.why
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defs.Read++
|
||||||
|
for _, p := range r.pins {
|
||||||
|
k := Key(p.Repository, p.Digest)
|
||||||
|
defs.Pins[k] = append(defs.Pins[k], Pin{Module: r.module, Name: p.Name, Older: p.Older})
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
break collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range list.Modules {
|
||||||
|
if !got[m.Module] {
|
||||||
|
defs.Unread[m.Module] = fmt.Sprintf("not read within %d s", int(DefinitionsTimeout/time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range defs.Pins {
|
||||||
|
sort.Slice(defs.Pins[k], func(i, j int) bool { return defs.Pins[k][i].Module < defs.Pins[k][j].Module })
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// definition reads one module's definition, or answers it from what was read before.
|
||||||
|
func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey string) ([]pinned, string) {
|
||||||
|
key := m.Module + "@" + m.Commit + "|" + nameKey
|
||||||
|
if m.Commit != "" {
|
||||||
|
c.mu.Lock()
|
||||||
|
pins, had := c.kept[key]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if had {
|
||||||
|
return pins, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := map[string]any{"module": m.Module}
|
||||||
|
if m.Commit != "" {
|
||||||
|
args["commit"] = m.Commit
|
||||||
|
}
|
||||||
|
raw, err := c.Ask(CatalogueModule, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "could not be asked: " + err.Error()
|
||||||
|
}
|
||||||
|
answer, output, ok := answerOf(raw)
|
||||||
|
if !ok {
|
||||||
|
return nil, "refused: " + lastLine(output)
|
||||||
|
}
|
||||||
|
var one struct {
|
||||||
|
Module *struct {
|
||||||
|
Manifest json.RawMessage `json:"manifest"`
|
||||||
|
} `json:"module"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(answer, &one); err != nil || one.Error != "" || one.Module == nil || len(one.Module.Manifest) == 0 {
|
||||||
|
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
|
||||||
|
}
|
||||||
|
pins := PinsIn(one.Module.Manifest, names)
|
||||||
|
if m.Commit != "" {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.kept[key] = pins
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
return pins, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// why says which definitions name a manifest, and under which of the store's names.
|
||||||
|
func (p Pin) why() string {
|
||||||
|
if p.Older {
|
||||||
|
return fmt.Sprintf("a definition names it: %s, under %s, an older name of the store", p.Module, shownName(p.Name))
|
||||||
|
}
|
||||||
|
return "a definition names it: " + p.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(s ...string) string {
|
||||||
|
for _, x := range s {
|
||||||
|
if strings.TrimSpace(x) != "" {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func errText(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shownName is a name of the store as a person reads it: a host without the slash a pin puts after it, the
|
||||||
|
// scheme as written.
|
||||||
|
func shownName(name string) string {
|
||||||
|
if strings.HasSuffix(name, "://") {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(name, "/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A definition may pin an image of the store the mesh did not make, under a name the store had before
|
||||||
|
// (novox/hq issue 323): three apps the anchor runs are pinned so. No record names them; a definition does.
|
||||||
|
|
||||||
|
const oldName = "registry-api.example.org"
|
||||||
|
|
||||||
|
// container is a definition whose one container runs image.
|
||||||
|
func container(image string) string {
|
||||||
|
return fmt.Sprintf(`{"module":"x","resources":[{"id":"server","type":"container","image":%q}]}`, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnImageADefinitionPinsUnderAnOlderNameOfTheStoreIsNamedByADefinition(t *testing.T) {
|
||||||
|
v := view(t, world())
|
||||||
|
names := NamesOf("store:5000", oldName)
|
||||||
|
defs := &Definitions{Pins: map[string][]Pin{}}
|
||||||
|
for module, manifest := range map[string]string{
|
||||||
|
// m-stranger: no record names it; the invoicing app pins it under the store's older name.
|
||||||
|
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||||
|
// m-old: the records say eligible; a definition pinning it does not change what the records say.
|
||||||
|
"website": container("store:5000/app/server@" + d("m-old")),
|
||||||
|
} {
|
||||||
|
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||||
|
defs.Pins[Key(p.Repository, p.Digest)] = append(defs.Pins[Key(p.Repository, p.Digest)], Pin{Module: module, Name: p.Name, Older: p.Older})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := stateByName(Classify(v, withFill(records()), defs))
|
||||||
|
stranger := got["app/server "+d("m-stranger")]
|
||||||
|
if stranger.State != StateNamedByDefinition {
|
||||||
|
t.Fatalf("a manifest a definition pins under an older name reads %q, want %q", stranger.State, StateNamedByDefinition)
|
||||||
|
}
|
||||||
|
if want := []string{"a definition names it: invoicing, under " + oldName + ", an older name of the store"}; !reflect.DeepEqual(stranger.Why, want) {
|
||||||
|
t.Errorf("why %q, want %q", stranger.Why, want)
|
||||||
|
}
|
||||||
|
if s := got["app/server "+d("m-old")].State; s != StateEligible {
|
||||||
|
t.Errorf("a recorded manifest a definition pins reads %q: the records decide what the mesh made", s)
|
||||||
|
}
|
||||||
|
// Without the definitions it is what it was: unrecorded.
|
||||||
|
if s := stateByName(Classify(v, withFill(records()), nil))["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||||
|
t.Errorf("without definitions %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinsAreFoundInEveryStringAndOnlyUnderTheStoresNames(t *testing.T) {
|
||||||
|
names := NamesOf("store:5000", oldName+", older.example/")
|
||||||
|
manifest := `{"resources":[
|
||||||
|
{"type":"container","image":"` + oldName + `/team/www@` + d("www") + `"},
|
||||||
|
{"type":"file","content":"IMAGE=older.example/team/tool@` + d("tool") + `\n"},
|
||||||
|
{"type":"container","image":"artifact-store://mod/art@` + d("built") + `"},
|
||||||
|
{"type":"container","image":"store:5000/mod/art@` + d("addressed") + `"},
|
||||||
|
{"type":"container","image":"not` + oldName + `/team/www@` + d("lookalike") + `"},
|
||||||
|
{"type":"container","image":"docker.io/library/nginx@` + d("upstream") + `"},
|
||||||
|
{"type":"container","image":"` + oldName + `/team/www:latest"}],
|
||||||
|
"build":{"on":{"base":"` + oldName + `/team/www@` + d("www") + `"}}}`
|
||||||
|
var got []string
|
||||||
|
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||||
|
got = append(got, fmt.Sprintf("%s@%s %s %v", p.Repository, p.Digest, p.Name, p.Older))
|
||||||
|
}
|
||||||
|
want := map[string]bool{
|
||||||
|
"team/www@" + d("www") + " " + oldName + "/ true": true,
|
||||||
|
"team/tool@" + d("tool") + " older.example/ true": true,
|
||||||
|
"mod/art@" + d("built") + " artifact-store:// false": true,
|
||||||
|
"mod/art@" + d("addressed") + " store:5000/ false": true,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("found %d pins, want %d (one per image and name; not a longer host's tail, not another registry, not a tag): %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for _, g := range got {
|
||||||
|
if !want[g] {
|
||||||
|
t.Errorf("found %s", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPlatformTakesTheStateOfAnIndexADefinitionNames(t *testing.T) {
|
||||||
|
v := mirrorWorld()
|
||||||
|
defs := &Definitions{Pins: map[string][]Pin{
|
||||||
|
Key("route-proxy/on-go_base", d("i-pre")): {{Module: "route-proxy", Name: oldName + "/", Older: true}},
|
||||||
|
}}
|
||||||
|
got := stateByName(Classify(v, withFill(mirrorRecords()), defs))
|
||||||
|
for _, name := range []string{"i-pre", "p-pre"} {
|
||||||
|
if s := got["route-proxy/on-go_base "+d(name)].State; s != StateNamedByDefinition {
|
||||||
|
t.Errorf("%s reads %q, want %q", name, s, StateNamedByDefinition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := got["route-proxy/on-go_base "+d("p-pre")]; e.Index != d("i-pre") {
|
||||||
|
t.Errorf("p-pre takes its state from %q", e.Index)
|
||||||
|
}
|
||||||
|
if s := got["route-proxy/on-go_base "+d("p-lost")].State; s != StateUnrecorded {
|
||||||
|
t.Errorf("a platform nothing names reads %q", s)
|
||||||
|
}
|
||||||
|
for _, um := range UnrecordedMirrors(v, Classify(v, withFill(mirrorRecords()), defs)) {
|
||||||
|
if strings.Contains(um.Reference, hexOf(d("i-pre"))) {
|
||||||
|
t.Errorf("a copy a definition names is offered to the mirrors verb: %v", um)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// olderNames writes the file the mesh writes from the setting older-store-names.
|
||||||
|
func olderNames(t *testing.T, names string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "store-names")
|
||||||
|
if err := os.WriteFile(path, []byte("# written by the mesh\nolder="+names+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(NamesFileEnv, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheOlderNamesAreReadFromTheFileTheMeshWrites(t *testing.T) {
|
||||||
|
olderNames(t, oldName+" older.example")
|
||||||
|
got, why := OlderNames()
|
||||||
|
if why != "" || !reflect.DeepEqual(NamesOf("", got).Older, []string{oldName + "/", "older.example/"}) {
|
||||||
|
t.Errorf("read %q (%s)", got, why)
|
||||||
|
}
|
||||||
|
olderNames(t, "none")
|
||||||
|
if got, why := OlderNames(); why != "" || len(NamesOf("", got).Older) != 0 {
|
||||||
|
t.Errorf("none read as %q (%s)", got, why)
|
||||||
|
}
|
||||||
|
t.Setenv(NamesFileEnv, filepath.Join(t.TempDir(), "absent"))
|
||||||
|
if _, why := OlderNames(); why == "" {
|
||||||
|
t.Error("an absent file was not said")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOlderNamesNotReadAreSaidFirst(t *testing.T) {
|
||||||
|
t.Setenv(NamesFileEnv, "")
|
||||||
|
a := &asked{definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger"))}}
|
||||||
|
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]any)
|
||||||
|
if w := m["said"].([]string)[0]; !strings.Contains(w, NamesFileEnv) {
|
||||||
|
t.Errorf("first said %q", w)
|
||||||
|
}
|
||||||
|
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||||
|
t.Errorf("m-stranger reads %q without the older names", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle.
|
||||||
|
func references(t *testing.T, a *asked) func(map[string]any) (any, error) {
|
||||||
|
f := world()
|
||||||
|
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||||
|
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||||
|
if tl.Name == "artifact_store_references" {
|
||||||
|
return tl.Run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no artifact_store_references")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestsOf(t *testing.T, out any) map[string]Entry {
|
||||||
|
t.Helper()
|
||||||
|
entries, ok := out.(map[string]any)["manifests"].([]Entry)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no manifests in %v", out)
|
||||||
|
}
|
||||||
|
return stateByName(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
|
||||||
|
olderNames(t, oldName)
|
||||||
|
a := &asked{definitions: map[string]string{
|
||||||
|
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||||
|
"grafana": container("grafana/grafana@" + d("upstream")),
|
||||||
|
}}
|
||||||
|
run := references(t, a)
|
||||||
|
out, err := run(map[string]any{"repository": "app/server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := manifestsOf(t, out)["app/server "+d("m-stranger")]
|
||||||
|
if e.State != StateNamedByDefinition || len(e.Why) != 1 || !strings.Contains(e.Why[0], "invoicing") || !strings.Contains(e.Why[0], oldName) {
|
||||||
|
t.Errorf("m-stranger: %+v", e)
|
||||||
|
}
|
||||||
|
said := out.(map[string]any)["definitions"].(map[string]any)
|
||||||
|
if said["read"] != 2 || !reflect.DeepEqual(said["older_store_names"], []string{oldName}) ||
|
||||||
|
!reflect.DeepEqual(said["store_names"], []string{"artifact-store://", "store:5000"}) {
|
||||||
|
t.Errorf("definitions said %v", said)
|
||||||
|
}
|
||||||
|
if w := out.(map[string]any)["said"].([]string)[0]; strings.Contains(w, "could not be read") {
|
||||||
|
t.Errorf("a warning with every definition read: %s", w)
|
||||||
|
}
|
||||||
|
// A definition read at its commit is kept: the next call asks only for the list.
|
||||||
|
before := 0
|
||||||
|
for _, c := range a.calls {
|
||||||
|
if c == CatalogueModule {
|
||||||
|
before++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"repository": "app/server"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := 0
|
||||||
|
for _, c := range a.calls {
|
||||||
|
if c == CatalogueModule {
|
||||||
|
after++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before != 2 || after != 2 {
|
||||||
|
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADefinitionNotReadIsSaidFirstAndLeavesWhatItPinsUnrecorded(t *testing.T) {
|
||||||
|
olderNames(t, oldName)
|
||||||
|
a := &asked{
|
||||||
|
definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger")), "grafana": container("x")},
|
||||||
|
failing: map[string]bool{"invoicing": true},
|
||||||
|
}
|
||||||
|
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]any)
|
||||||
|
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||||
|
t.Errorf("m-stranger reads %q with its definition unread", s)
|
||||||
|
}
|
||||||
|
if w := m["said"].([]string)[0]; !strings.Contains(w, "1 definitions could not be read") {
|
||||||
|
t.Errorf("first said %q", w)
|
||||||
|
}
|
||||||
|
if u := m["definitions"].(map[string]any)["unread"].(map[string]string); u["invoicing"] == "" || len(u) != 1 {
|
||||||
|
t.Errorf("unread %v", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue not answering at all: the records are still answered, and the warning stands first.
|
||||||
|
a = &asked{noCatalogue: true}
|
||||||
|
out, err = references(t, a)(map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m = out.(map[string]any)
|
||||||
|
if w := m["said"].([]string)[0]; !strings.Contains(w, "the definitions could not be read") || !strings.Contains(w, "no responders") {
|
||||||
|
t.Errorf("first said %q", w)
|
||||||
|
}
|
||||||
|
if m["definitions"].(map[string]any)["not_asked"] == nil {
|
||||||
|
t.Errorf("definitions %v", m["definitions"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoCatalogueIsSaidAndNothingIsAsked(t *testing.T) {
|
||||||
|
defs := (&Catalogue{}).Read(context.Background(), NamesOf("", ""))
|
||||||
|
if defs.NotAsked == "" || defs.Read != 0 {
|
||||||
|
t.Errorf("%+v", defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func main() {
|
|||||||
UID: os.Getuid(),
|
UID: os.Getuid(),
|
||||||
}
|
}
|
||||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask})); err != nil {
|
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask}, &Catalogue{Ask: stdio.Ask})); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
|||||||
for i := range recs.References {
|
for i := range recs.References {
|
||||||
fill(&recs.References[i])
|
fill(&recs.References[i])
|
||||||
}
|
}
|
||||||
got := stateByName(Classify(v, recs))
|
got := stateByName(Classify(v, recs, nil))
|
||||||
up := "upstream/docker.io/library/golang "
|
up := "upstream/docker.io/library/golang "
|
||||||
for name, want := range map[string]string{
|
for name, want := range map[string]string{
|
||||||
up + d("p-amd"): StateEligible, // only the eligible index names it
|
up + d("p-amd"): StateEligible, // only the eligible index names it
|
||||||
@@ -92,7 +92,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
|||||||
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
|
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
|
||||||
t.Errorf("offered as unrecorded copies: %v", mirrors)
|
t.Errorf("offered as unrecorded copies: %v", mirrors)
|
||||||
}
|
}
|
||||||
um := UnrecordedMirrors(v, Classify(v, recs))
|
um := UnrecordedMirrors(v, Classify(v, recs, nil))
|
||||||
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
|
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
|
||||||
t.Errorf("unrecorded copies: %+v", um)
|
t.Errorf("unrecorded copies: %+v", um)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func TestLettingAnIndexGoTakesItsOwnPlatformsAndSaysWhatStaysAndNamesOne(t *test
|
|||||||
for i := range recs.References {
|
for i := range recs.References {
|
||||||
fill(&recs.References[i])
|
fill(&recs.References[i])
|
||||||
}
|
}
|
||||||
entries := Classify(v, recs)
|
entries := Classify(v, recs, nil)
|
||||||
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
|
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
|
||||||
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
|
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
|
||||||
up := "upstream/docker.io/library/golang"
|
up := "upstream/docker.io/library/golang"
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
|
|||||||
func TestEveryStateIsGiven(t *testing.T) {
|
func TestEveryStateIsGiven(t *testing.T) {
|
||||||
v := view(t, world())
|
v := view(t, world())
|
||||||
got := map[string]string{}
|
got := map[string]string{}
|
||||||
for _, e := range Classify(v, withFill(records())) {
|
for _, e := range Classify(v, withFill(records()), nil) {
|
||||||
got[e.Repository+" "+e.Digest] = e.State
|
got[e.Repository+" "+e.Digest] = e.State
|
||||||
}
|
}
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
@@ -239,7 +239,7 @@ func TestALetGoReferenceStillPresentIsSaidSo(t *testing.T) {
|
|||||||
v := view(t, world())
|
v := view(t, world())
|
||||||
r := records()
|
r := records()
|
||||||
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
|
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
|
||||||
for _, e := range Classify(v, withFill(r)) {
|
for _, e := range Classify(v, withFill(r), nil) {
|
||||||
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
|
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
|
||||||
t.Errorf("state %s", e.State)
|
t.Errorf("state %s", e.State)
|
||||||
}
|
}
|
||||||
@@ -270,7 +270,7 @@ func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
|
|||||||
|
|
||||||
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
|
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
|
||||||
v := view(t, world())
|
v := view(t, world())
|
||||||
entries := Classify(v, withFill(records()))
|
entries := Classify(v, withFill(records()), nil)
|
||||||
sums := Summarise(v, entries, entries)
|
sums := Summarise(v, entries, entries)
|
||||||
el := sums[StateEligible]
|
el := sums[StateEligible]
|
||||||
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
|
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
|
||||||
@@ -284,6 +284,11 @@ type asked struct {
|
|||||||
calls []string
|
calls []string
|
||||||
args []map[string]any
|
args []map[string]any
|
||||||
fail map[string]bool
|
fail map[string]bool
|
||||||
|
// definitions are the catalogue's modules and their manifests; failing, the modules whose
|
||||||
|
// definition cannot be read; noCatalogue, the catalogue not answering at all.
|
||||||
|
definitions map[string]string
|
||||||
|
failing map[string]bool
|
||||||
|
noCatalogue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||||
@@ -299,6 +304,31 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
|||||||
switch key {
|
switch key {
|
||||||
case "seat:mesh-controller.artifacts":
|
case "seat:mesh-controller.artifacts":
|
||||||
answer = records()
|
answer = records()
|
||||||
|
case CatalogueModules:
|
||||||
|
if a.noCatalogue {
|
||||||
|
return nil, fmt.Errorf("no responders")
|
||||||
|
}
|
||||||
|
ms := []map[string]any{}
|
||||||
|
for name := range a.definitions {
|
||||||
|
ms = append(ms, map[string]any{"module": name, "commit": "c-" + name, "path": "modules/" + name})
|
||||||
|
}
|
||||||
|
answer = map[string]any{"modules": ms}
|
||||||
|
case CatalogueModule:
|
||||||
|
name, _ := args["module"].(string)
|
||||||
|
if a.failing[name] {
|
||||||
|
return nil, fmt.Errorf("no answer")
|
||||||
|
}
|
||||||
|
if args["commit"] != "c-"+name {
|
||||||
|
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
|
||||||
|
}
|
||||||
|
manifest, has := a.definitions[name]
|
||||||
|
if !has {
|
||||||
|
answer = map[string]any{"error": "the catalogue holds no " + name}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// The catalogue's tool answers through the runtime as the protocol's content list.
|
||||||
|
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
|
||||||
|
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
|
||||||
case "seat:mesh-controller.collect":
|
case "seat:mesh-controller.collect":
|
||||||
if args["confirm"] == "true" {
|
if args["confirm"] == "true" {
|
||||||
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
|
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
|
||||||
@@ -315,7 +345,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
|||||||
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
|
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
|
||||||
f := world()
|
f := world()
|
||||||
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||||
for _, tl := range Tools(s, Controller{Ask: a.ask}) {
|
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||||
if tl.Name == name {
|
if tl.Name == name {
|
||||||
return tl.Run
|
return tl.Run
|
||||||
}
|
}
|
||||||
@@ -386,7 +416,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var served []string
|
var served []string
|
||||||
for _, tl := range Tools(&Store{}, Controller{}) {
|
for _, tl := range Tools(&Store{}, Controller{}, &Catalogue{}) {
|
||||||
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
|
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
|
||||||
t.Errorf("tool %q", tl.Name)
|
t.Errorf("tool %q", tl.Name)
|
||||||
}
|
}
|
||||||
@@ -398,7 +428,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(served, listed) {
|
if !reflect.DeepEqual(served, listed) {
|
||||||
t.Fatalf("served %v, manifest %v", served, listed)
|
t.Fatalf("served %v, manifest %v", served, listed)
|
||||||
}
|
}
|
||||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect"}
|
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect", CatalogueModules, CatalogueModule}
|
||||||
if !reflect.DeepEqual(m.Invokes, want) {
|
if !reflect.DeepEqual(m.Invokes, want) {
|
||||||
t.Errorf("invokes %v", m.Invokes)
|
t.Errorf("invokes %v", m.Invokes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
|
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
|
||||||
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
|
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
|
||||||
func Tools(s *Store, c Controller) []stdio.Tool {
|
func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
|
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
|
||||||
return []stdio.Tool{
|
return []stdio.Tool{
|
||||||
@@ -53,7 +53,9 @@ func Tools(s *Store, c Controller) []stdio.Tool {
|
|||||||
{
|
{
|
||||||
Name: "artifact_store_references",
|
Name: "artifact_store_references",
|
||||||
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
||||||
"names it, or one of the five most recent builds of a module the mesh holds), the holder of a kept archive, " +
|
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
|
||||||
|
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||||
|
"an older one: the module is said), the holder of a kept archive, " +
|
||||||
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
||||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||||
"takes the state of the recorded index naming it. Counted and sized by " +
|
"takes the state of the recorded index naming it. Counted and sized by " +
|
||||||
@@ -67,11 +69,19 @@ func Tools(s *Store, c Controller) []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// The definitions are read while the store is: each bounded below the runtime's call limit.
|
||||||
|
read := make(chan *Definitions, 1)
|
||||||
|
go func() {
|
||||||
|
older, why := OlderNames()
|
||||||
|
defs := cat.Read(ctx, NamesOf(recs.Store, older))
|
||||||
|
defs.NamesNotRead = why
|
||||||
|
read <- defs
|
||||||
|
}()
|
||||||
v, err := s.View(ctx)
|
v, err := s.View(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return References(v, recs, repo)
|
return References(v, recs, <-read, repo)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +126,7 @@ func Tools(s *Store, c Controller) []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
|
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
|
||||||
}
|
}
|
||||||
return Collected(v, answer, dry, Classify(v, recs)), nil
|
return Collected(v, answer, dry, Classify(v, recs, nil)), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -225,11 +235,11 @@ func Usage(v *View, top int) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// References answers artifact_store_references.
|
// References answers artifact_store_references.
|
||||||
func References(v *View, recs *Records, only string) (map[string]any, error) {
|
func References(v *View, recs *Records, defs *Definitions, only string) (map[string]any, error) {
|
||||||
if only != "" && v.L.Repos[only] == nil {
|
if only != "" && v.L.Repos[only] == nil {
|
||||||
return nil, fmt.Errorf("the store holds no repository %q", only)
|
return nil, fmt.Errorf("the store holds no repository %q", only)
|
||||||
}
|
}
|
||||||
entries := Classify(v, recs)
|
entries := Classify(v, recs, defs)
|
||||||
byRepo := map[string][]Entry{}
|
byRepo := map[string][]Entry{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
byRepo[e.Repository] = append(byRepo[e.Repository], e)
|
byRepo[e.Repository] = append(byRepo[e.Repository], e)
|
||||||
@@ -243,7 +253,7 @@ func References(v *View, recs *Records, only string) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
whole := Summarise(v, entries, entries)
|
whole := Summarise(v, entries, entries)
|
||||||
said := []string{}
|
said := []string{}
|
||||||
for _, state := range []string{StateKept, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
for _, state := range []string{StateKept, StateNamedByDefinition, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
||||||
if s := whole[state]; s != nil {
|
if s := whole[state]; s != nil {
|
||||||
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
|
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
|
||||||
}
|
}
|
||||||
@@ -276,9 +286,57 @@ func References(v *View, recs *Records, only string) (map[string]any, error) {
|
|||||||
if u := unreadNote(v); u != nil {
|
if u := unreadNote(v); u != nil {
|
||||||
out["unread"] = u
|
out["unread"] = u
|
||||||
}
|
}
|
||||||
|
out["definitions"] = definitionsSaid(defs)
|
||||||
|
if warn := definitionsWarning(defs); warn != "" {
|
||||||
|
// Said first: what it leaves out is a manifest a machine may run, read as unrecorded.
|
||||||
|
out["said"] = append([]string{warn}, said...)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// definitionsSaid is how the definitions were read: how many, under which names, and what was not read.
|
||||||
|
func definitionsSaid(defs *Definitions) map[string]any {
|
||||||
|
if defs == nil {
|
||||||
|
return map[string]any{"read": 0, "not_asked": "the definitions were not read"}
|
||||||
|
}
|
||||||
|
var current, older []string
|
||||||
|
for _, n := range defs.Names.Current {
|
||||||
|
current = append(current, shownName(n))
|
||||||
|
}
|
||||||
|
for _, n := range defs.Names.Older {
|
||||||
|
older = append(older, shownName(n))
|
||||||
|
}
|
||||||
|
out := map[string]any{"read": defs.Read, "store_names": nonNil(current), "older_store_names": nonNil(older)}
|
||||||
|
if defs.NotAsked != "" {
|
||||||
|
out["not_asked"] = defs.NotAsked
|
||||||
|
}
|
||||||
|
if defs.NamesNotRead != "" {
|
||||||
|
out["older_store_names_not_read"] = defs.NamesNotRead
|
||||||
|
}
|
||||||
|
if len(defs.Unread) > 0 {
|
||||||
|
out["unread"] = defs.Unread
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// definitionsWarning says, when any definition was not read, that a manifest it pins reads as unrecorded.
|
||||||
|
func definitionsWarning(defs *Definitions) string {
|
||||||
|
switch {
|
||||||
|
case defs == nil:
|
||||||
|
return ""
|
||||||
|
case defs.NotAsked != "":
|
||||||
|
return "the definitions could not be read (" + defs.NotAsked + "), so a manifest a definition pins " +
|
||||||
|
"and no record names reads as unrecorded here: ask again before reading unrecorded as unused"
|
||||||
|
case defs.NamesNotRead != "":
|
||||||
|
return defs.NamesNotRead + ", so a manifest a definition pins under an older name of the store and no " +
|
||||||
|
"record names reads as unrecorded here"
|
||||||
|
case len(defs.Unread) > 0:
|
||||||
|
return fmt.Sprintf("%d definitions could not be read (listed in definitions.unread), so a manifest one "+
|
||||||
|
"of them pins and no record names reads as unrecorded here: ask again — what was read is kept", len(defs.Unread))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
|
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
|
||||||
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
|
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
|
||||||
refs := a.LetGo
|
refs := a.LetGo
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
|
||||||
|
# artifact_store_references reads every definition under these too (novox/hq issue 323).
|
||||||
|
# Replaced on every push; change the setting, never this file.
|
||||||
|
older=${config:older-store-names}
|
||||||
@@ -3,13 +3,39 @@
|
|||||||
"version": "1",
|
"version": "1",
|
||||||
"invokes": [
|
"invokes": [
|
||||||
"seat:mesh-controller.artifacts",
|
"seat:mesh-controller.artifacts",
|
||||||
"seat:mesh-controller.collect"
|
"seat:mesh-controller.collect",
|
||||||
|
"mesh-catalog.catalog_modules",
|
||||||
|
"mesh-catalog.catalog_module"
|
||||||
],
|
],
|
||||||
"tools": [
|
"tools": [
|
||||||
"artifact_store_repositories",
|
"artifact_store_repositories",
|
||||||
"artifact_store_usage",
|
"artifact_store_usage",
|
||||||
"artifact_store_references",
|
"artifact_store_references",
|
||||||
"artifact_store_collect"
|
"artifact_store_collect"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"older-store-names": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
||||||
|
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||||
|
"default": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "store-names",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/store-names",
|
||||||
|
"mode": "0600",
|
||||||
|
"trusted": false,
|
||||||
|
"template": "files/store-names"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
@@ -24,8 +50,14 @@
|
|||||||
"artifact-store-tools"
|
"artifact-store-tools"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry"
|
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||||
|
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
|||||||
home := "/home/operator"
|
home := "/home/operator"
|
||||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||||
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||||
var own string
|
own := m.i3Lines()
|
||||||
for _, c := range m.Contributions {
|
if own == "" || len(m.Contributions) != 0 {
|
||||||
if c.Seat == "node-display-session" {
|
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
||||||
own += c.Content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||||
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||||
|
|||||||
@@ -15,19 +15,41 @@ type manifest struct {
|
|||||||
Tools []string `json:"tools"`
|
Tools []string `json:"tools"`
|
||||||
Emits []string `json:"emits"`
|
Emits []string `json:"emits"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
||||||
Contributions []struct {
|
// triggers, its text the template file beside the manifest.
|
||||||
Seat string `json:"seat"`
|
Items []struct {
|
||||||
Kind string `json:"kind"`
|
Collection string `json:"collection"`
|
||||||
Content string `json:"content"`
|
Template string `json:"template"`
|
||||||
} `json:"contributions"`
|
} `json:"items"`
|
||||||
|
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
||||||
|
Contributions []any `json:"contributions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
||||||
|
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
for _, it := range m.Items {
|
||||||
|
if it.Collection == "contribution:node-display-session:config" {
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
||||||
|
// item of the collection contribution:node-hotkeys:trigger.
|
||||||
func (m manifest) triggers() string {
|
func (m manifest) triggers() string {
|
||||||
for _, c := range m.Contributions {
|
for _, it := range m.Items {
|
||||||
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
if it.Collection == "contribution:node-hotkeys:trigger" {
|
||||||
return c.Content
|
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
||||||
|
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
||||||
|
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Templates string `json:"templates"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if m.Templates != "templates" {
|
||||||
|
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
||||||
|
}
|
||||||
|
files := 0
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files++
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if !strings.HasPrefix(name, "templates/") {
|
||||||
|
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
||||||
|
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files == 0 {
|
||||||
|
t.Fatal("no file resource")
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "templates" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "templates" {
|
||||||
|
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
// The templates archive is read by the controller, never unpacked onto the machine.
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["artifact"] == "templates" {
|
||||||
|
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And files/ holds what the machine runs, no template.
|
||||||
|
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
||||||
|
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"module": "asus-zephyrus-g14",
|
"module": "asus-zephyrus-g14",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
|
"templates": "templates",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager",
|
"package-manager",
|
||||||
"service-manager"
|
"service-manager"
|
||||||
@@ -59,14 +61,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
"template": "templates/modprobe.d/g14-nvidia-power.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "video-options",
|
"id": "video-options",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
"template": "templates/modprobe.d/video-brightness-switch.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-drop-ins",
|
"id": "suspend-drop-ins",
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hibernate-drop-ins",
|
"id": "hibernate-drop-ins",
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-then-hibernate-drop-ins",
|
"id": "suspend-then-hibernate-drop-ins",
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "powerd-drop-ins",
|
"id": "powerd-drop-ins",
|
||||||
@@ -118,14 +120,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "backlight-rule",
|
"id": "backlight-rule",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/udev/rules.d/90-backlight.rules",
|
"path": "/etc/udev/rules.d/90-backlight.rules",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
"template": "templates/udev/rules.d/90-backlight.rules"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "udev",
|
"id": "udev",
|
||||||
@@ -151,7 +153,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "upower",
|
"id": "upower",
|
||||||
@@ -174,7 +176,22 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"collection": "shell:after-wake:normal",
|
||||||
|
"template": "templates/after-wake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "contribution:node-hotkeys:trigger",
|
||||||
|
"template": "templates/hotkeys-triggers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "contribution:node-display-session:config",
|
||||||
|
"name": "display-session",
|
||||||
|
"template": "templates/display-session"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -194,31 +211,12 @@
|
|||||||
"name": "scripts",
|
"name": "scripts",
|
||||||
"kind": "archive",
|
"kind": "archive",
|
||||||
"from": "files"
|
"from": "files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "templates",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "templates"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"contributions": [
|
|
||||||
{
|
|
||||||
"seat": "node-hotkeys",
|
|
||||||
"kind": "trigger",
|
|
||||||
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"seat": "node-display-session",
|
|
||||||
"kind": "config",
|
|
||||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"seat": "node-display-session",
|
|
||||||
"kind": "config",
|
|
||||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shell": [
|
|
||||||
{
|
|
||||||
"for": "after-wake",
|
|
||||||
"slot": "normal",
|
|
||||||
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
|
||||||
|
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
|
||||||
|
[UPower]
|
||||||
|
UsePercentageForPolicy=true
|
||||||
|
PercentageLow=15.0
|
||||||
|
PercentageCritical=10.0
|
||||||
|
PercentageAction=7.0
|
||||||
|
CriticalPowerAction=Suspend
|
||||||
|
AllowRiskyCriticalPowerAction=true
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The touchpad's settings, applied by X every time the device appears — at login and after every
|
||||||
|
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
|
||||||
|
# xinput after a resume as a named person on a guessed display.
|
||||||
|
Section "InputClass"
|
||||||
|
Identifier "asus-zephyrus-g14 touchpad"
|
||||||
|
MatchIsTouchpad "on"
|
||||||
|
Option "Tapping" "on"
|
||||||
|
Option "NaturalScrolling" "true"
|
||||||
|
Option "AccelSpeed" "0.15"
|
||||||
|
EndSection
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||||
|
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||||
|
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||||
|
sleep 2
|
||||||
|
owner=$(ps -o user= -C i3 | head -n 1)
|
||||||
|
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
|
true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||||
|
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||||
|
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||||
|
#
|
||||||
|
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
|
||||||
|
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
|
||||||
|
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
|
||||||
|
|
||||||
|
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
|
||||||
|
# screenshot grabs the pointer to select a region, which fails while the key is still held.
|
||||||
|
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||||
|
|
||||||
|
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
|
||||||
|
# external output.
|
||||||
|
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
|
||||||
|
|
||||||
|
# The keyboard backlight's level, shown when it changes.
|
||||||
|
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
|
||||||
|
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||||
|
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||||
|
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||||
|
#
|
||||||
|
# The model's panel and touchpad.
|
||||||
|
|
||||||
|
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
|
||||||
|
# is the display server's (autorandr, run at every session start).
|
||||||
|
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
|
||||||
|
|
||||||
|
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
|
||||||
|
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||||
|
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||||
|
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||||
|
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||||
|
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||||
|
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||||
|
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||||
|
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||||
|
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||||
|
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
|
||||||
|
#
|
||||||
|
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
|
||||||
|
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
|
||||||
|
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
|
||||||
|
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
|
||||||
|
#
|
||||||
|
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
|
||||||
|
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
|
||||||
|
# module's drop-ins on the sleep services ask for.
|
||||||
|
#
|
||||||
|
# A change here applies when the driver next loads: at the next boot.
|
||||||
|
options nvidia NVreg_PreserveVideoMemoryAllocations=1
|
||||||
|
options nvidia NVreg_DynamicPowerManagement=0x00
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
|
||||||
|
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
|
||||||
|
# Applies when the module next loads: at the next boot.
|
||||||
|
options video brightness_switch_enabled=0
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
|
||||||
|
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
|
||||||
|
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
|
||||||
|
[Unit]
|
||||||
|
ConditionKernelCommandLine=zephyrus.nvidia-powerd
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-hibernate.service nvidia-resume.service
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-suspend.service nvidia-resume.service
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
|
||||||
|
# move the panel without root.
|
||||||
|
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"password": "${secret:admin}",
|
||||||
|
"host": "${bound:route:name}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_HOST=${bound:postgres-database:at}
|
||||||
|
DATABASE_PORT=${bound:postgres-database:port}
|
||||||
|
DATABASE_NAME=${bound:postgres-database:as}
|
||||||
|
DATABASE_USER=${bound:postgres-database:as}
|
||||||
|
DATABASE_PASSWORD_FILE=/run/secrets/database
|
||||||
|
DISABLE_EMBEDDED_PSQL=true
|
||||||
|
BASEROW_PUBLIC_URL=https://${bound:route:name}
|
||||||
@@ -46,6 +46,26 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"config": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": "${bound:route:name}"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -70,7 +90,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
|
"template": "files/server.env"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
@@ -102,8 +122,10 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:mesh-state}/config.json",
|
"path": "${dir:mesh-state}/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
"template": "files/config.json",
|
||||||
"merge": "json"
|
"merge": "json",
|
||||||
|
"takes-config": ["host", "email", "url"],
|
||||||
|
"protected": ["password"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -122,6 +144,11 @@
|
|||||||
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
||||||
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-43
@@ -1,10 +1,12 @@
|
|||||||
# betterbird
|
# betterbird
|
||||||
|
|
||||||
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
|
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, ADR 0208, ADR 0316, ADR 0317,
|
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316,
|
||||||
to-be 56). It requires `x11-display`, so it is assigned only where a display server is held on the same
|
ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held
|
||||||
machine. It installs Betterbird from a pinned upstream release, ships Owl with it through Betterbird's
|
on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through
|
||||||
enterprise policy, and declares the operator's mail accounts without their credentials.
|
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
|
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
|
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
|
||||||
@@ -18,13 +20,15 @@ issue 345.
|
|||||||
| Betterbird, the upstream release (`archive`, pinned by URL and sha256) | `/opt/betterbird` | owned, whole |
|
| 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 |
|
| 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 |
|
| Owl's xpi (`owl`, pinned by the vendor's sha256) | `/opt/betterbird/owl` | owned, whole |
|
||||||
| the spelling dictionaries' default (`install/defaults-pref/vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its one file |
|
| 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 tools' program, for the start's preparation | `/usr/local/lib/mesh-betterbird` | owned, whole |
|
||||||
| the start | `/usr/local/bin/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 launcher entry | `/usr/local/share/applications/eu.betterbird.Betterbird.desktop` | owned, whole |
|
||||||
| the module's directory, with its `user.js` (setting `prefs`), `accounts.json` (setting `accounts`) and the record of adopted and created accounts | `~/.config/betterbird/` | 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 profile's `user.js`, placed by the tools | the profile | the module's, or never touched |
|
||||||
| the account lists in the profile's `prefs.js` | the profile | written into: the module's keys only |
|
| 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 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
|
**The community package `betterbird-bin` is declared absent** (ADR 0180), before anything is placed in
|
||||||
@@ -56,42 +60,97 @@ honours) and installs Owl into every profile from `file:///opt/betterbird/owl/ow
|
|||||||
`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy
|
`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
|
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
|
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. **Owl's licence stays the person's**: the mesh holds no licence, key or payment (ADR 0316 §9).
|
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
|
**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
|
(`/usr/local/bin/betterbird`) first runs `betterbird-tools prepare`, which, with no Betterbird of the
|
||||||
account running, places the user.js and writes the created accounts into the account lists; then it
|
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
|
starts Betterbird either way. `betterbird_restart` starts it as the transient user unit
|
||||||
`mesh-betterbird`, so its output then goes to the journal.
|
`mesh-betterbird`, so its output then goes to the journal.
|
||||||
|
|
||||||
|
|
||||||
## The user.js: how a preference is declared
|
## The preferences: locks, the setting prefs, and Betterbird's own defaults
|
||||||
|
|
||||||
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the
|
Betterbird's preferences come in three layers (novox/hq issue 345, issue 586). **The operator's own choices
|
||||||
one it saved in `prefs.js`. The profile's directory has a random name per machine
|
live in the module's configuration, never in the catalogue**: the module ships only its policy's locks and the
|
||||||
(`<random>.default-default`), which a manifest cannot name. So:
|
names it keeps at Betterbird's own default.
|
||||||
|
|
||||||
1. the node-engine writes `~/.config/betterbird/user.js` from the module's setting `prefs`, one line of
|
| layer | where | who changes it | holds |
|
||||||
`user_pref("name", value);` statements (default: a comment, no preference);
|
|---|---|---|---|
|
||||||
2. the module's tools copy that file whole into the profile: `betterbird_user_js` with `apply`, or
|
| **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 |
|
||||||
`betterbird_restart`, which places it before it starts Betterbird again;
|
| **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 |
|
||||||
3. Betterbird reads it at that start.
|
| **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: <name>` 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 (`<random>.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
|
**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`.
|
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
|
||||||
On the workstation measured these differ: `Profile1` is marked `Default=1`, and the installation's
|
|
||||||
default, the one in use, is the other.
|
|
||||||
|
|
||||||
**A user.js the module did not write is never overwritten.** The module's begins with the line
|
**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
|
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with what to do.
|
||||||
what to do. The tools refuse a declared file that holds anything but comments and `user_pref`
|
|
||||||
statements, or that sets a preference whose name can hold a credential.
|
|
||||||
|
|
||||||
The file is **trusted** (`"trusted": true`): a preference can weaken how the mail client checks a server,
|
|
||||||
so the setting is changed only at the controller's terminal or proposed and set on the operator's warrant
|
|
||||||
(novox/hq issue 339, ADR 0277). An account's preferences (`mail.account.`, `mail.identity.`, `mail.server.`,
|
|
||||||
`mail.smtpserver.` and the account lists) are refused in `prefs`: an account is declared in `accounts`, so
|
|
||||||
it has one owner (ADR 0316 §11).
|
|
||||||
## The accounts: declared without credentials (ADR 0316 §10–§16, ADR 0317)
|
## 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
|
The setting `accounts` reaches the machine as `~/.config/betterbird/accounts.json`, a trusted mergeable
|
||||||
@@ -137,30 +196,80 @@ lines and its record go, the account stays the person's) and takes a created acc
|
|||||||
lists (its mail stays in the profile).
|
lists (its mail stays in the profile).
|
||||||
|
|
||||||
**The value is derived, not retyped** (§9): `betterbird_accounts` with `propose` builds `accounts` from the
|
**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 through the controller's settings verb;
|
profile (every account but Owl's, no credential) and proposes it in the mesh-wide layer as it stands, through
|
||||||
the operator reads it whole at their desk (`settings proposals <id>`) and approves on a channel that proves
|
the controller's settings verb; the operator approves on a channel that proves who answers. Its answer shows
|
||||||
who answers. Its answer to its caller holds counts and kinds only.
|
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
|
**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
|
node set up from nothing, the declared accounts are written at the second start from the launcher (or at
|
||||||
`betterbird_restart`).
|
`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_<name>`, `mesh_<name>` 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/<profile>/`; 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
|
## Tools
|
||||||
|
|
||||||
They are served by the node's runtime as the operator account (ADR 0175). **No answer carries mail, a
|
They are served by the node's runtime as the operator account (ADR 0175). **They show the operator's own
|
||||||
calendar entry, an address, a host, an account's name or a credential**: accounts are said by count, place
|
configuration with its values, and never a credential** (ADR 0329 §1, §2): every answer passes the module's
|
||||||
and key.
|
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 |
|
| tool | does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `betterbird_status` (r) | whether it runs (pid, since, scope, children); what is installed and from where (the mesh's release, or the community package before the switch), its version and build beside the newest published build (Betterbird's own `versions.txt`); 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); the declared accounts by count and, per place, adopted, created or refused with their keys; the add-ons in the profile (ids, versions); the preferences that decide how its windows and notifications look |
|
| `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 (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked |
|
| `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_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it |
|
| `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_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where |
|
| `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_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it |
|
| `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_check` (r) | installed from the mesh's release and not behind the newest published build; the policy placed; Owl at its pin and active; one running; profile found; the user.js on the machine, valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; its output read |
|
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked |
|
||||||
| `betterbird_accounts` (r; a with `propose`) | the profile's accounts by count and kind (declarable, Owl's, others); with `propose`, the setting `accounts` built from them and proposed to the operator (ADR 0317 §9) |
|
| `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) |
|
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
|
||||||
|
|
||||||
### A reminder on demand
|
### A reminder on demand
|
||||||
@@ -223,8 +332,9 @@ outside the mesh. With this version, a push to a node (each waits for the operat
|
|||||||
| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens |
|
| `/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 |
|
| 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 |
|
| `/usr/local` | the start, the launcher entry, the tools' program |
|
||||||
| `~/.config/betterbird/` | `user.js`, `accounts.json` (no account until the setting `accounts` is approved) |
|
| `~/.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 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
|
## Relies on
|
||||||
|
|
||||||
@@ -234,4 +344,6 @@ outside the mesh. With this version, a push to a node (each waits for the operat
|
|||||||
- `systemd-run` and `journalctl` of the account's user manager.
|
- `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
|
- 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.
|
not know it refuses this manifest.
|
||||||
- The controller's `settings` verb, for `betterbird_accounts` with `propose` (`invokes`).
|
- 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`).
|
||||||
|
|||||||
@@ -398,10 +398,6 @@ func unquote(v string) string {
|
|||||||
// (§12): the declared accounts' keys added at the end, the module's keys of accounts no longer declared taken
|
// (§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.
|
// 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) {
|
func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount) (string, error) {
|
||||||
if ps := m.mainProcs(); len(ps) > 0 {
|
|
||||||
return "", fmt.Errorf("Betterbird is running (pid %d), and it rewrites prefs.js when it quits: the account "+
|
|
||||||
"list is written at its next start from the launcher, or by betterbird_restart", ps[0].PID)
|
|
||||||
}
|
|
||||||
path := filepath.Join(profile, "prefs.js")
|
path := filepath.Join(profile, "prefs.js")
|
||||||
raw, err := readBounded(path)
|
raw, err := readBounded(path)
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -418,16 +414,15 @@ func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount)
|
|||||||
if !changed {
|
if !changed {
|
||||||
return "unchanged", nil
|
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)
|
mode := os.FileMode(0o600)
|
||||||
if st, err := os.Stat(path); err == nil {
|
if st, err := os.Stat(path); err == nil {
|
||||||
mode = st.Mode().Perm()
|
mode = st.Mode().Perm()
|
||||||
}
|
}
|
||||||
tmp := path + ".mesh-new"
|
if err := writeAtomic(path, []byte(out), mode); err != nil {
|
||||||
if err := os.WriteFile(tmp, []byte(out), mode); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, path); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil
|
return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil
|
||||||
@@ -608,11 +603,15 @@ func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountsAnswer is what betterbird_accounts answers its caller: counts and kinds, never an address, a host or
|
// AccountsAnswer is what betterbird_accounts answers its caller: the profile's accounts with the operator's own
|
||||||
// a name (novox/hq ADR 0317 §9). The value itself goes only to the operator, in a proposal they read at
|
// values (novox/hq ADR 0329 §1, §12), Owl's marked and by name and address only (§13), and counts and kinds.
|
||||||
// their own desk.
|
// Never a password, a token or anything of the login store (§2).
|
||||||
type AccountsAnswer struct {
|
type AccountsAnswer struct {
|
||||||
Profile string `json:"profile"`
|
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 are the profile's mail accounts a declaration can hold (IMAP and POP3), by kind.
|
||||||
Declarable map[string]int `json:"declarable"`
|
Declarable map[string]int `json:"declarable"`
|
||||||
// KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13).
|
// KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13).
|
||||||
@@ -626,6 +625,20 @@ type AccountsAnswer struct {
|
|||||||
Note string `json:"note"`
|
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
|
// profileDeclarable reads the profile's accounts: the value a declaration of them would be, by name, and
|
||||||
// what is answered of them.
|
// what is answered of them.
|
||||||
func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) {
|
func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) {
|
||||||
@@ -634,13 +647,28 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
|
|||||||
return nil, AccountsAnswer{}, err
|
return nil, AccountsAnswer{}, err
|
||||||
}
|
}
|
||||||
prefs := mergedPrefs(p.path)
|
prefs := mergedPrefs(p.path)
|
||||||
a := AccountsAnswer{Profile: p.Dir, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
|
a := AccountsAnswer{Profile: p.Dir, Accounts: []listedAccount{}, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
|
||||||
value := map[string]Account{}
|
value := map[string]Account{}
|
||||||
used := map[string]bool{}
|
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) {
|
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 {
|
switch {
|
||||||
case pa.Owl:
|
case pa.Owl:
|
||||||
a.KeptByOwl++
|
a.KeptByOwl++
|
||||||
|
listed.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
|
||||||
|
a.Accounts = append(a.Accounts, listed)
|
||||||
continue
|
continue
|
||||||
case pa.Kind != "imap" && pa.Kind != "pop3":
|
case pa.Kind != "imap" && pa.Kind != "pop3":
|
||||||
kind := pa.Kind
|
kind := pa.Kind
|
||||||
@@ -648,6 +676,8 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
|
|||||||
kind = "local folders"
|
kind = "local folders"
|
||||||
}
|
}
|
||||||
a.NotDeclarable[kind]++
|
a.NotDeclarable[kind]++
|
||||||
|
listed.Note = "not an account a declaration holds"
|
||||||
|
a.Accounts = append(a.Accounts, listed)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.Declarable[pa.Kind]++
|
a.Declarable[pa.Kind]++
|
||||||
@@ -659,8 +689,14 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
|
|||||||
address = pa.Addresses[0]
|
address = pa.Addresses[0]
|
||||||
}
|
}
|
||||||
name := nameFor(pa, address, used)
|
name := nameFor(pa, address, used)
|
||||||
|
if declaredAs[pa.Key] != "" {
|
||||||
|
name = declaredAs[pa.Key]
|
||||||
|
}
|
||||||
used[name] = true
|
used[name] = true
|
||||||
value[name] = readAccount(prefs, pa)
|
acc := readAccount(prefs, pa)
|
||||||
|
value[name] = acc
|
||||||
|
listed.Value = &acc
|
||||||
|
a.Accounts = append(a.Accounts, listed)
|
||||||
}
|
}
|
||||||
if len(a.NotDeclarable) == 0 {
|
if len(a.NotDeclarable) == 0 {
|
||||||
a.NotDeclarable = nil
|
a.NotDeclarable = nil
|
||||||
@@ -681,9 +717,9 @@ func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
a.Note = "counts and kinds only: no address, host or name leaves through this answer. With propose, the " +
|
a.Note = "the profile's accounts with their values, never a password or token (novox/hq ADR 0329 §1, §2). With " +
|
||||||
"setting accounts is built from these accounts (never Owl's, never a password or token) and proposed to " +
|
"propose, the setting accounts is built from them (never Owl's) and proposed to the operator in the mesh-wide " +
|
||||||
"the operator, who reads it whole at their desk before approving (novox/hq ADR 0317 §9)"
|
"layer as it stands, who approves it on a channel that proves who answers (novox/hq ADR 0317 §9)"
|
||||||
if !propose {
|
if !propose {
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@@ -701,21 +737,16 @@ func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
|
|||||||
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
|
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
|
||||||
strings.Join(problems, "; "))
|
strings.Join(problems, "; "))
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(map[string]any{"accounts": value})
|
// 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 {
|
if err != nil {
|
||||||
return a, err
|
return a, fmt.Errorf("nothing was proposed: %v", err)
|
||||||
}
|
}
|
||||||
raw, err := asker("seat:mesh-controller.settings", map[string]any{"module": "betterbird", "values": string(body),
|
layer["accounts"] = value
|
||||||
"propose": "true"})
|
a.Derived = value
|
||||||
if err != nil {
|
said, err := proposeLayer("", layer, false)
|
||||||
return a, fmt.Errorf("the controller's settings verb did not answer: %v", err)
|
|
||||||
}
|
|
||||||
said, ok := verbSaid(raw)
|
|
||||||
a.Proposed = m.maskValue(said)
|
a.Proposed = m.maskValue(said)
|
||||||
if !ok {
|
return a, err
|
||||||
return a, fmt.Errorf("the controller did not take the proposal: %s", a.Proposed)
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
|
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
|
||||||
|
|||||||
@@ -357,9 +357,12 @@ func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredential(t *testing.T) {
|
// 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 := newFake(t)
|
||||||
f.profileWithAccounts()
|
f.profileWithAccounts()
|
||||||
|
f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"imap://imap.example.org","encryptedUsername":"MDoEEPgAAAAA","encryptedPassword":"hunter2secret"}]}`)
|
||||||
links(t, nil)
|
links(t, nil)
|
||||||
a, err := f.ProfileAccounts(false)
|
a, err := f.ProfileAccounts(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,19 +371,33 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
|
|||||||
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
|
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
|
||||||
t.Fatalf("%+v", a)
|
t.Fatalf("%+v", a)
|
||||||
}
|
}
|
||||||
raw, _ := json.Marshal(a)
|
raw, _ := json.Marshal(credentialChecked(a))
|
||||||
for _, never := range []string{"example", "other", "Other", "work@", "smtp1"} {
|
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) {
|
if strings.Contains(string(raw), never) {
|
||||||
t.Errorf("the answer to the caller names %q: %s", never, raw)
|
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
|
var asked []map[string]any
|
||||||
asker = func(key string, body any) (json.RawMessage, error) {
|
asker = func(key string, body any) (json.RawMessage, error) {
|
||||||
if key != "seat:mesh-controller.settings" {
|
if key != "seat:mesh-controller.settings" {
|
||||||
t.Fatalf("asked %s", key)
|
t.Fatalf("asked %s", key)
|
||||||
}
|
}
|
||||||
asked = body.(map[string]any)
|
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
|
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 })
|
t.Cleanup(func() { asker = nil })
|
||||||
@@ -388,10 +405,15 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if asked["module"] != "betterbird" || asked["propose"] != "true" || asked["node"] != nil {
|
if len(asked) != 2 || asked[1]["module"] != "betterbird" || asked[1]["propose"] != "true" || asked[1]["node"] != nil ||
|
||||||
t.Fatalf("proposed for the whole mesh, never set: %v", asked)
|
asked[1]["replace"] != nil {
|
||||||
|
t.Fatalf("the layer read, then proposed for the whole mesh, never set, never removing: %v", asked)
|
||||||
}
|
}
|
||||||
value, err := parseAccounts([]byte(asked["values"].(string)))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("the proposed value is one the module takes: %v", err)
|
t.Fatalf("the proposed value is one the module takes: %v", err)
|
||||||
}
|
}
|
||||||
@@ -400,6 +422,9 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
|
|||||||
other.Incoming.Port != 993 || other.Outgoing.Security != "tls" || other.Outgoing.Port != 465 {
|
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)
|
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") {
|
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)
|
t.Fatalf("the controller's words come back with every address masked: %s", a.Proposed)
|
||||||
}
|
}
|
||||||
@@ -516,3 +541,27 @@ func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *
|
|||||||
t.Fatalf("%v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, h
|
|||||||
users := map[string][]string{}
|
users := map[string][]string{}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
use := func(id string) {
|
use := func(id string) {
|
||||||
if seen[id] || ours[id] {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
seen[id] = true
|
seen[id] = true
|
||||||
|
|||||||
@@ -9,20 +9,20 @@ package main
|
|||||||
// preferences asked for are answered and never one that can hold a credential or names an account
|
// preferences asked for are answered and never one that can hold a credential or names an account
|
||||||
// (passwordSecret, accountPrefix);
|
// (passwordSecret, accountPrefix);
|
||||||
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
|
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
|
||||||
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
|
// - the module's settings as the node-engine writes them: prefs (~/.config/betterbird/prefs.json, prefs.go),
|
||||||
// the module's setting `prefs`, and its declared accounts (~/.config/betterbird/accounts.json, the
|
// accounts (~/.config/betterbird/accounts.json, accounts.go), and identities, calendars, address-books,
|
||||||
// setting `accounts`, accounts.go);
|
// 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,
|
// - 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).
|
// to count the declared accounts that have not signed in yet (novox/hq ADR 0316 §16).
|
||||||
//
|
//
|
||||||
// Never read: the mail store, the address books, a user name or password in logins.json, key4.db,
|
// 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 caches. Nothing of the operator's mail or calendar is answered, and of
|
// cert9.db, cookies, the calendar's events and caches. The operator's own configuration is answered with its
|
||||||
// their accounts only counts and kinds.
|
// 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 (the declared file and
|
// The files a tool writes in the profile are user.js, and only the module's own (betterbird_user_js with apply,
|
||||||
// the declared accounts' preferences, betterbird_user_js with apply, betterbird_restart, and the start
|
// betterbird_restart, and the start from the launcher); and, only while Betterbird is not running, the module's
|
||||||
// from the launcher), and the module's own keys in the account lists of prefs.js, only while Betterbird is
|
// own keys in the lists of prefs.js, the removals of what it created, and each declared account's
|
||||||
// not running (accounts.go). A user.js that is not the module's is never overwritten.
|
// msgFilterRules.dat (accounts.go, owned.go). A user.js that is not the module's is never overwritten.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -51,8 +51,6 @@ const (
|
|||||||
testUnit = "mesh-betterbird-reminder-test"
|
testUnit = "mesh-betterbird-reminder-test"
|
||||||
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
|
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
|
||||||
profilesDir = ".thunderbird"
|
profilesDir = ".thunderbird"
|
||||||
// declared is the module's user.js, written by the node-engine from the setting prefs.
|
|
||||||
declared = ".config/betterbird/user.js"
|
|
||||||
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
|
// 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"
|
mark = "// Betterbird's user.js (module betterbird"
|
||||||
)
|
)
|
||||||
@@ -263,11 +261,15 @@ func readPrefs(raw []byte) map[string]string {
|
|||||||
var (
|
var (
|
||||||
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
|
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.",
|
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
|
||||||
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
|
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.",
|
||||||
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
|
"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
|
// 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.
|
// hold anything, and a theme's id is what a window's look depends on.
|
||||||
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
|
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
|
// accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so
|
||||||
@@ -288,16 +290,24 @@ func withheld(name string) string {
|
|||||||
return "it names an account, a server, a calendar or a person"
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pref is one preference as the profile holds it.
|
// Pref is one preference as the profile holds it.
|
||||||
type Pref struct {
|
type Pref struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default.
|
// Value is what Betterbird uses: the policy's lock, else user.js's at the start, else prefs.js's, else the
|
||||||
Value string `json:"value,omitempty"`
|
// default, when a defaults file gives it.
|
||||||
From string `json:"from,omitempty"`
|
Value string `json:"value,omitempty"`
|
||||||
Withheld string `json:"withheld,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.
|
// mostPrefs bounds how many preferences one answer carries.
|
||||||
@@ -319,7 +329,9 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||||
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
|
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
|
||||||
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
|
fromPrefs, fromUser := readPrefs(prefsJS), statementsOf(userJS)
|
||||||
|
own, ownErr := m.defaults(true)
|
||||||
|
locked := m.locks()
|
||||||
all := map[string]bool{}
|
all := map[string]bool{}
|
||||||
for n := range fromPrefs {
|
for n := range fromPrefs {
|
||||||
all[n] = true
|
all[n] = true
|
||||||
@@ -354,25 +366,40 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
pr := Pref{Name: n}
|
pr := Pref{Name: n}
|
||||||
|
d, hasDefault := own[n]
|
||||||
v, from := fromPrefs[n], "prefs.js"
|
v, from := fromPrefs[n], "prefs.js"
|
||||||
if u, ok := fromUser[n]; ok {
|
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)"
|
v, from = u, "user.js (wins at the start)"
|
||||||
} else if _, ok := fromPrefs[n]; !ok {
|
} else if _, ok := fromPrefs[n]; !ok {
|
||||||
from = "not set: Betterbird's default"
|
switch {
|
||||||
v = ""
|
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
|
pr.From = from
|
||||||
|
if hasDefault {
|
||||||
|
pr.DefaultFrom = d.From
|
||||||
|
}
|
||||||
if why := withheld(n); why != "" {
|
if why := withheld(n); why != "" {
|
||||||
pr.Withheld = why
|
pr.Withheld = why
|
||||||
} else if allowedExtensions[n] {
|
} else if allowedExtensions[n] {
|
||||||
pr.Value = v
|
pr.Value = v
|
||||||
|
pr.Default = d.Value
|
||||||
} else {
|
} else {
|
||||||
pr.Value = m.maskValue(v)
|
pr.Value = m.maskValue(v)
|
||||||
|
pr.Default = m.maskValue(d.Value)
|
||||||
}
|
}
|
||||||
out = append(out, pr)
|
out = append(out, pr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
answer := map[string]any{"profile": p, "prefs": out}
|
answer := map[string]any{"profile": p, "prefs": out}
|
||||||
|
if ownErr != nil {
|
||||||
|
answer["defaults_problem"] = ownErr.Error()
|
||||||
|
}
|
||||||
if cut {
|
if cut {
|
||||||
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
|
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
|
||||||
}
|
}
|
||||||
@@ -411,6 +438,12 @@ func validUserJS(text string) error {
|
|||||||
"accounts, never in prefs (novox/hq ADR 0316 §11)", i+1, name)
|
"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
|
return nil
|
||||||
@@ -460,6 +493,16 @@ type UserJS struct {
|
|||||||
Each []resolvedAccount `json:"accounts,omitempty"`
|
Each []resolvedAccount `json:"accounts,omitempty"`
|
||||||
// Lists says whether the keys of the accounts the module created were written into the account lists.
|
// Lists says whether the keys of the accounts the module created were written into the account lists.
|
||||||
Lists string `json:"account_lists,omitempty"`
|
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.
|
// refusedAccounts are the reasons of the declared accounts that were refused.
|
||||||
@@ -479,9 +522,12 @@ func (u UserJS) refusedAccounts() []string {
|
|||||||
// it created into the profile's account lists when Betterbird is not running. A refused account gets nothing;
|
// 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).
|
// the others go ahead (novox/hq ADR 0317 §7).
|
||||||
func (m *Machine) userJS(apply bool) (UserJS, error) {
|
func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||||
a := UserJS{Declared: "~/" + declared}
|
a := UserJS{Declared: "~/" + prefsFile}
|
||||||
declaredText, err := readBounded(m.home(declared))
|
declaredText, present, derr := m.declaredText()
|
||||||
a.Present = err == nil
|
a.Present = present
|
||||||
|
if derr != nil {
|
||||||
|
a.Present, a.Valid = true, derr.Error()
|
||||||
|
}
|
||||||
p, perr := m.profile()
|
p, perr := m.profile()
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return a, perr
|
return a, perr
|
||||||
@@ -489,19 +535,27 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
|||||||
target := filepath.Join(p.path, "user.js")
|
target := filepath.Join(p.path, "user.js")
|
||||||
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
|
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
|
||||||
accounts, _, aerr := m.readAccounts()
|
accounts, _, aerr := m.readAccounts()
|
||||||
|
merged := mergedPrefs(p.path)
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
a.AccountsProblem = aerr.Error()
|
a.AccountsProblem = aerr.Error()
|
||||||
accounts = nil
|
accounts = nil
|
||||||
} else {
|
} else {
|
||||||
a.Accounts = len(accounts)
|
a.Accounts = len(accounts)
|
||||||
a.Each = resolveAccounts(accounts, mergedPrefs(p.path), m.readRecord(p.Dir))
|
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
|
var want []byte
|
||||||
if a.Present {
|
if a.Present && a.Valid == "" {
|
||||||
if verr := validUserJS(string(declaredText)); verr != nil {
|
if verr := validUserJS(declaredText); verr != nil {
|
||||||
a.Valid = verr.Error()
|
a.Valid = verr.Error()
|
||||||
}
|
}
|
||||||
want = []byte(composeUserJS(string(declaredText), accounts, a.Each))
|
want = []byte(composeUserJS(declaredText, accounts, a.Each) + plan.configBlock())
|
||||||
a.Content = m.answeredUserJS(string(want))
|
a.Content = m.answeredUserJS(string(want))
|
||||||
for _, line := range strings.Split(string(want), "\n") {
|
for _, line := range strings.Split(string(want), "\n") {
|
||||||
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
||||||
@@ -525,11 +579,13 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
|||||||
if apply {
|
if apply {
|
||||||
switch {
|
switch {
|
||||||
case !a.Present:
|
case !a.Present:
|
||||||
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
|
return a, fmt.Errorf("the setting prefs is not on this machine (~/%s): the module is not pushed here yet", prefsFile)
|
||||||
case a.Valid != "":
|
case a.Valid != "":
|
||||||
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
|
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
|
||||||
case a.AccountsProblem != "":
|
case a.AccountsProblem != "":
|
||||||
return a, fmt.Errorf("nothing was placed: %s", 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"))
|
saved, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||||
savedPrefs := readPrefs(saved)
|
savedPrefs := readPrefs(saved)
|
||||||
@@ -561,6 +617,16 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
|||||||
} else {
|
} else {
|
||||||
a.Lists = lists
|
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 ps := m.mainProcs(); len(ps) > 0 {
|
||||||
if st, err := os.Stat(target); err == nil {
|
if st, err := os.Stat(target); err == nil {
|
||||||
@@ -611,6 +677,20 @@ func (m *Machine) Prepare() error {
|
|||||||
problems = append(problems, "the account lists were "+u.Lists)
|
problems = append(problems, "the account lists were "+u.Lists)
|
||||||
}
|
}
|
||||||
problems = append(problems, u.refusedAccounts()...)
|
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 {
|
if len(problems) > 0 {
|
||||||
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||||
}
|
}
|
||||||
@@ -814,7 +894,7 @@ func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
|
|||||||
a.UserJS = "not placed: " + err.Error()
|
a.UserJS = "not placed: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
|
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
|
||||||
u.Statements, declared, u.Accounts, u.Lists)
|
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,
|
// 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.
|
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
|
||||||
@@ -849,7 +929,8 @@ type CheckAnswer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check verifies what the module promises: Betterbird installed, one running in the session, its
|
// 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, and its output read.
|
// 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) {
|
func (m *Machine) Check() (CheckAnswer, error) {
|
||||||
a := CheckAnswer{Findings: []Finding{}}
|
a := CheckAnswer{Findings: []Finding{}}
|
||||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||||
@@ -875,6 +956,35 @@ func (m *Machine) Check() (CheckAnswer, error) {
|
|||||||
if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") {
|
if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") {
|
||||||
add("the enterprise policy is "+in.Policy, "push the module to this node")
|
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 {
|
switch {
|
||||||
case in.From != "the mesh":
|
case in.From != "the mesh":
|
||||||
case in.Owl == nil || in.Owl.Pinned == "":
|
case in.Owl == nil || in.Owl.Pinned == "":
|
||||||
@@ -903,6 +1013,23 @@ func (m *Machine) Check() (CheckAnswer, error) {
|
|||||||
add(fmt.Sprintf("%d declared account(s) have not signed in yet", c.WithoutSignIn),
|
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)")
|
"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()
|
ps := m.mainProcs()
|
||||||
switch {
|
switch {
|
||||||
@@ -916,7 +1043,7 @@ func (m *Machine) Check() (CheckAnswer, error) {
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
add("no profile: "+err.Error(), "start Betterbird once")
|
add("no profile: "+err.Error(), "start Betterbird once")
|
||||||
case !u.Present:
|
case !u.Present:
|
||||||
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
|
add("the setting prefs is not on this machine (~/"+prefsFile+")", "the module is not pushed here: the controller's push")
|
||||||
case u.Valid != "":
|
case u.Valid != "":
|
||||||
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
|
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
|
||||||
case u.AccountsProblem != "":
|
case u.AccountsProblem != "":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,8 +45,20 @@ func (f *fake) betterbird() {
|
|||||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
|
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) {
|
func (f *fake) declare(prefs string) {
|
||||||
f.write(testHome+"/.config/betterbird/user.js", mark+", novox/hq issue 345). Owned by the mesh.\n//\n"+prefs+"\n")
|
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) {
|
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
|
||||||
@@ -106,9 +119,9 @@ func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
|
|||||||
t.Fatalf("%+v %v", u, err)
|
t.Fatalf("%+v %v", u, err)
|
||||||
}
|
}
|
||||||
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
|
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
|
||||||
declared, _ := os.ReadFile(filepath.Join(f.Root, testHome, ".config/betterbird/user.js"))
|
declared, _, _ := f.declaredText()
|
||||||
if string(placed) != string(declared) {
|
if string(placed) != declared || !strings.Contains(declared, `user_pref("ui.key.x", "a;b");`) {
|
||||||
t.Fatal("placed whole, as declared")
|
t.Fatalf("placed whole, as declared:\n%s", placed)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(u.Effective, "yes") {
|
if !strings.HasPrefix(u.Effective, "yes") {
|
||||||
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
|
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
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 <id>` 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ const (
|
|||||||
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
|
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
|
||||||
owlFile = installDir + "/owl/owl.xpi"
|
owlFile = installDir + "/owl/owl.xpi"
|
||||||
owlID = "owl@beonex.com"
|
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 is the tools' program as the module places it for its start (betterbird-tools prepare).
|
||||||
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
|
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
|
||||||
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
|
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
|
||||||
@@ -158,7 +162,7 @@ func newestBuild(version, build string) (string, bool) {
|
|||||||
return "unknown: " + versionsURL + " names no build for " + major, false
|
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 say.
|
// 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 {
|
func (m *Machine) policyState() string {
|
||||||
raw, err := readBounded(m.path(policyFile))
|
raw, err := readBounded(m.path(policyFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,14 +171,25 @@ func (m *Machine) policyState() string {
|
|||||||
if why := policyProblem(raw); why != "" {
|
if why := policyProblem(raw); why != "" {
|
||||||
return "placed, and " + why
|
return "placed, and " + why
|
||||||
}
|
}
|
||||||
return "placed: Betterbird's updater off, Owl installed into every profile from the mesh's xpi, its updates off"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
// policyProblem is what the policy fails to say of what ADR 0316 asks, or "".
|
// 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 {
|
func policyProblem(raw []byte) string {
|
||||||
var p struct {
|
var p struct {
|
||||||
Policies struct {
|
Policies struct {
|
||||||
DisableAppUpdate bool `json:"DisableAppUpdate"`
|
DisableAppUpdate bool `json:"DisableAppUpdate"`
|
||||||
|
DisableTelemetry bool `json:"DisableTelemetry"`
|
||||||
|
ExtensionUpdate *bool `json:"ExtensionUpdate"`
|
||||||
ExtensionSettings map[string]struct {
|
ExtensionSettings map[string]struct {
|
||||||
Mode string `json:"installation_mode"`
|
Mode string `json:"installation_mode"`
|
||||||
URL string `json:"install_url"`
|
URL string `json:"install_url"`
|
||||||
@@ -185,18 +200,57 @@ func policyProblem(raw []byte) string {
|
|||||||
if err := json.Unmarshal(raw, &p); err != nil {
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
return "it is not JSON: " + err.Error()
|
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]
|
owl, ok := p.Policies.ExtensionSettings[owlID]
|
||||||
switch {
|
switch {
|
||||||
case !p.Policies.DisableAppUpdate:
|
|
||||||
return "it does not turn Betterbird's updater off (DisableAppUpdate)"
|
|
||||||
case !ok || owl.Mode != "force_installed":
|
case !ok || owl.Mode != "force_installed":
|
||||||
return "it does not install Owl into every profile (force_installed)"
|
problems = append(problems, "it does not install Owl into every profile (force_installed)")
|
||||||
case owl.URL != "file://"+owlFile:
|
case owl.URL != "file://"+owlFile:
|
||||||
return "it installs Owl from " + owl.URL + ", not from the mesh's xpi"
|
problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi")
|
||||||
case !owl.Disabled:
|
case !owl.Disabled:
|
||||||
return "it leaves Owl's updates on"
|
problems = append(problems, "it leaves Owl's updates on")
|
||||||
}
|
}
|
||||||
return ""
|
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.
|
// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read.
|
||||||
|
|||||||
@@ -172,3 +172,60 @@ func TestTheUserJSDescriptionSaysAdoption(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@
|
|||||||
// mail and calendar client, in the operator's session, served by the node's runtime as the operator
|
// 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.
|
// account. The module holds no seat, so every tool is its own.
|
||||||
//
|
//
|
||||||
// The operator's mail, calendars, accounts and credentials are never read: a tool reads processes,
|
// The operator's mail, calendar events, contacts and credentials are never read: a tool reads processes,
|
||||||
// profiles.ini, and of the profile only prefs.js, user.js and extensions.json, and answers no
|
// profiles.ini, and of the profile its prefs.js, user.js, extensions.json, each account's msgFilterRules.dat and
|
||||||
// preference that can hold a credential or names an account.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
@@ -34,6 +37,31 @@ func main() {
|
|||||||
var machine = NewMachine()
|
var machine = NewMachine()
|
||||||
|
|
||||||
func tools() []stdio.Tool {
|
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{
|
return []stdio.Tool{
|
||||||
{
|
{
|
||||||
Name: "betterbird_status",
|
Name: "betterbird_status",
|
||||||
@@ -42,17 +70,19 @@ func tools() []stdio.Tool {
|
|||||||
"or the community package before the switch), its version and build beside the newest published build; " +
|
"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; " +
|
"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, " +
|
"where its output goes; the profile it uses and how that was found; the module's user.js (declared, " +
|
||||||
"placed, in effect); the declared accounts by count (in the profile's list, other accounts, not signed " +
|
"placed, in effect, with every declared identity, calendar, address book and tag as it resolved); the " +
|
||||||
"in yet), never an address, host or name; the add-ons in the profile; and the preferences that decide " +
|
"declared accounts by count (in the profile's list, other accounts, not signed in yet); the add-ons in the " +
|
||||||
"how its windows and notifications look. (r)",
|
"profile; and the preferences that decide how its windows and notifications look. (r)",
|
||||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "betterbird_prefs",
|
Name: "betterbird_prefs",
|
||||||
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (user.js, " +
|
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (the " +
|
||||||
"which wins at the start; prefs.js; or Betterbird's default). A name ending in . or * is a prefix " +
|
"module's policy, which locks it; user.js, which wins at the start; prefs.js; or the default), and the " +
|
||||||
"(at most 200 answered). A preference whose name can hold a credential, or that names an account, a " +
|
"default itself with the file that gives it: Betterbird's own, read from omni.ja and the installation's " +
|
||||||
"server, a calendar or a person, is answered without its value. (r)",
|
"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{
|
Input: map[string]any{
|
||||||
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
|
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
|
||||||
@@ -65,17 +95,57 @@ func tools() []stdio.Tool {
|
|||||||
return machine.Prefs(names)
|
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",
|
Name: "betterbird_user_js",
|
||||||
Description: "The module's user.js — the preferences the module declares (setting prefs), then the declared " +
|
Description: "The module's user.js — the setting prefs, then the declared accounts' preferences (setting " +
|
||||||
"accounts' preferences under the module's own keys (setting accounts) — against the profile's user.js: " +
|
"accounts), then the declared identities, calendars, address books and tags — against the profile's " +
|
||||||
"whether it is placed, and whether the running Betterbird started after it was. With apply, place it in " +
|
"user.js: whether it is placed, and whether the running Betterbird started after it was. With apply, place " +
|
||||||
"the profile (only where the profile has no user.js or the module's own; an operator's file is never " +
|
"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 account the profile already holds at its address is adopted under its own keys; " +
|
"overwritten). A declared entry the profile already holds is adopted under its own keys; one it does not " +
|
||||||
"one it does not hold is created under the module's keys, which are added to the account lists when " +
|
"hold is created under the module's keys; an ambiguous one is refused alone, with the remedy (novox/hq ADR " +
|
||||||
"Betterbird is not running; an ambiguous one is refused alone, with the remedy (novox/hq ADR 0317). " +
|
"0317, ADR 0329 §7). What Betterbird rewrites itself — the account and identity lists, the calendars' " +
|
||||||
"Accounts' values are withheld in the answer. Betterbird reads it at its next start: betterbird_restart. " +
|
"order, the removals and each declared account's msgFilterRules.dat — is written only while Betterbird is " +
|
||||||
"(r; a with apply)",
|
"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{
|
Input: map[string]any{
|
||||||
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
|
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
|
||||||
},
|
},
|
||||||
@@ -135,21 +205,23 @@ func tools() []stdio.Tool {
|
|||||||
{
|
{
|
||||||
Name: "betterbird_check",
|
Name: "betterbird_check",
|
||||||
Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " +
|
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; Owl at its pin and active; one " +
|
"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 " +
|
"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; its output " +
|
"effect; the declared accounts in the profile's list, none refused, each signed in once; per kind, no " +
|
||||||
"read. Answers ok and each finding with what to do, never an address, host or name. (r)",
|
"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() },
|
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "betterbird_accounts",
|
Name: "betterbird_accounts",
|
||||||
Description: "The accounts of the profile Betterbird uses, by count and kind: the IMAP and POP3 accounts a " +
|
Description: "The accounts of the profile Betterbird uses with their values (name, address, user, servers' " +
|
||||||
"declaration can hold, the accounts Owl keeps (never declared), and the rest. Never an address, a host " +
|
"hosts and ports) and the declared account each is, Owl's marked by name and address (never declared), " +
|
||||||
"or a name, and never anything of the login store. With propose, it builds the module's setting " +
|
"with counts by kind. Never a password, a token or anything of the login store (novox/hq ADR 0329 §1, §2). " +
|
||||||
"accounts from them (every account but Owl's, without any password or token) and proposes it through " +
|
"With propose, it builds the module's setting accounts from them (every account but Owl's) and proposes " +
|
||||||
"the controller's settings verb for the operator's Approve on a channel that proves who answers " +
|
"it in the mesh-wide layer as it stands, through the controller's settings verb, for the operator's " +
|
||||||
"(novox/hq ADR 0317, ADR 0277): nothing changes until then, and the operator reads the value whole at " +
|
"Approve on a channel that proves who answers (novox/hq ADR 0317, ADR 0277); the answer shows the value it " +
|
||||||
"their desk. (r; a with propose)",
|
"proposed. (r; a with propose)",
|
||||||
Input: map[string]any{
|
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)"},
|
"propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"},
|
||||||
},
|
},
|
||||||
@@ -161,6 +233,76 @@ func tools() []stdio.Tool {
|
|||||||
return machine.ProfileAccounts(propose)
|
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",
|
Name: "betterbird_reminder_test",
|
||||||
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
|
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory
|
// 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 user.js the setting prefs fills and the accounts file the setting accounts fills, both
|
// in the home with the mergeable files the settings fill, each naming the keys it takes (ADR 0326): prefs, accounts
|
||||||
// trusted; the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
|
// 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
|
// 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;
|
// 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.
|
// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools.
|
||||||
@@ -25,7 +25,7 @@ type manifest struct {
|
|||||||
Upgrade map[string]string `json:"upgrade"`
|
Upgrade map[string]string `json:"upgrade"`
|
||||||
Requires []string `json:"requires"`
|
Requires []string `json:"requires"`
|
||||||
Invokes []string `json:"invokes"`
|
Invokes []string `json:"invokes"`
|
||||||
Settings map[string]any `json:"settings"`
|
Config map[string]any `json:"config"`
|
||||||
Tools []string `json:"tools"`
|
Tools []string `json:"tools"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
Build struct {
|
Build struct {
|
||||||
@@ -84,7 +84,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
ids = append(ids, r["id"].(string))
|
ids = append(ids, r["id"].(string))
|
||||||
}
|
}
|
||||||
want := "configuration-dir,user-js,accounts,community-package,installation,owl,policies,spelling,preparation,start,launcher"
|
want := "configuration-dir,prefs,accounts,configuration,tags,community-package,installation,owl,policies,spelling,preparation,start,launcher"
|
||||||
if strings.Join(ids, ",") != want {
|
if strings.Join(ids, ",") != want {
|
||||||
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
|
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
||||||
if strings.Contains(raw, never) {
|
if regexp.MustCompile(regexp.QuoteMeta(never) + `\b`).MatchString(raw) {
|
||||||
t.Errorf("module.json names %q: the profile is Betterbird's", never)
|
t.Errorf("module.json names %q: the profile is Betterbird's", never)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,29 +114,66 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheUserJSAndTheAccountsAreTrustedFilesOfTheModulesOwn(t *testing.T) {
|
// 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)
|
m, _ := readManifest(t)
|
||||||
file := resource(t, m, "user-js")
|
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
||||||
content := file["content"].(string)
|
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
||||||
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != true {
|
|
||||||
t.Fatalf("the user.js begins with the mark, holds the setting, and is trusted (only the terminal or the operator's warrant sets it): %v", file)
|
|
||||||
}
|
}
|
||||||
// The default is a comment, so a machine without the setting places a valid, empty user.js.
|
for key, raw := range m.Config {
|
||||||
def := m.Settings["prefs"].(map[string]any)["default"].(string)
|
d, _ := raw.(map[string]any)
|
||||||
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
|
def, _ := d["default"].(map[string]any)
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
acc := resource(t, m, "accounts")
|
files := map[string]struct {
|
||||||
if acc["path"] != "${machine:account-home}/"+accountsFile || acc["merge"] != "json" || acc["trusted"] != true ||
|
path, mode string
|
||||||
acc["mode"] != "0600" || acc["owner"] != "${machine:account}" {
|
takes []any
|
||||||
t.Fatalf("the setting accounts reaches the machine as a trusted mergeable file of the account's (ADR 0316 §10, §15): %v", acc)
|
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},
|
||||||
}
|
}
|
||||||
got, err := parseAccounts([]byte(acc["content"].(string)))
|
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 {
|
if err != nil || len(got) != 0 {
|
||||||
t.Fatalf("its default declares no account: %v %v", got, err)
|
t.Fatalf("its default declares no account: %v %v", got, err)
|
||||||
}
|
}
|
||||||
if _, declared := m.Settings["accounts"]; declared {
|
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
||||||
t.Fatal("accounts is the operator's value and has no default in the definition (ADR 0262)")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
|
|||||||
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
||||||
m, _ := readManifest(t)
|
m, _ := readManifest(t)
|
||||||
start := resource(t, m, "start")
|
start := resource(t, m, "start")
|
||||||
content := start["content"].(string)
|
content := templateText(t, start)
|
||||||
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
||||||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
!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)
|
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")
|
launcher := resource(t, m, "launcher")
|
||||||
lc := launcher["content"].(string)
|
lc := templateText(t, launcher)
|
||||||
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
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, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
||||||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
||||||
@@ -278,8 +315,10 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
|||||||
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-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)
|
t.Errorf("the Go tools bundle: %v", b)
|
||||||
}
|
}
|
||||||
// betterbird_accounts proposes the setting accounts through the controller's settings verb (ADR 0317 §9).
|
// The tools propose through the controller's settings verb (ADR 0317 §9), and betterbird_derive asks the controller
|
||||||
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings"}) {
|
// which nodes hold the module and each of them for its profile (ADR 0329 §10).
|
||||||
t.Errorf("the module invokes the controller's settings verb and nothing else: %v", m.Invokes)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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.<key>.….
|
||||||
|
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) }
|
||||||
@@ -165,8 +165,8 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
|
|||||||
// The module's user.js first, then what is carried, then the test's own preferences: the last
|
// 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.
|
// statement wins, so neither can take the test's calendar or its quiet start away.
|
||||||
user := ""
|
user := ""
|
||||||
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
|
if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil {
|
||||||
user = string(mine) + "\n"
|
user = mine + "\n"
|
||||||
}
|
}
|
||||||
user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root))
|
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 {
|
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"accounts": {}
|
||||||
|
}
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"identities": {},
|
||||||
|
"calendars": {},
|
||||||
|
"address-books": {},
|
||||||
|
"filters": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"prefs": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"tags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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.
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
{
|
{
|
||||||
"policies": {
|
"policies": {
|
||||||
"DisableAppUpdate": true,
|
"DisableAppUpdate": true,
|
||||||
|
"DisableTelemetry": true,
|
||||||
|
"ExtensionUpdate": false,
|
||||||
"ExtensionSettings": {
|
"ExtensionSettings": {
|
||||||
"owl@beonex.com": {
|
"owl@beonex.com": {
|
||||||
"installation_mode": "force_installed",
|
"installation_mode": "force_installed",
|
||||||
"install_url": "file:///opt/betterbird/owl/owl.xpi",
|
"install_url": "file:///opt/betterbird/owl/owl.xpi",
|
||||||
"updates_disabled": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user