Compare commits
308
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eacbd160d | ||
|
|
0f768bc97b | ||
|
|
721db20887 | ||
|
|
1fc7b84ad2 | ||
|
|
70b16dc594 | ||
|
|
d6b9cef9d0 | ||
|
|
b85d65b53f | ||
|
|
3bec357968 | ||
|
|
b60077850d | ||
|
|
a00962871c | ||
|
|
90ff4c485a | ||
|
|
f08abb90d4 | ||
|
|
a493c90c0d | ||
|
|
a1febd3ad8 | ||
|
|
14b7158fc0 | ||
|
|
0066b702f4 | ||
|
|
281909e83d | ||
|
|
d0a3a8ebc6 | ||
|
|
2246ccc492 | ||
|
|
93f4f67a8f | ||
|
|
78499235b6 | ||
|
|
ecd60fe074 | ||
|
|
dd74d4adee | ||
|
|
fa39738981 | ||
|
|
612486be18 | ||
|
|
3c5ff051de | ||
|
|
17e9efa101 | ||
|
|
3111546ff1 | ||
|
|
68dfca1057 | ||
|
|
77f33d317f | ||
|
|
2175b26c56 | ||
|
|
e21ccf1138 | ||
|
|
08442b95cb | ||
|
|
c304d3fb97 | ||
|
|
8356d30e1b | ||
|
|
6e823d1f9b | ||
|
|
d0ba022d20 | ||
|
|
6bfdbc5758 | ||
|
|
c4fc565180 | ||
|
|
db937546ae | ||
|
|
2c32728395 | ||
|
|
20ea65f0ac | ||
|
|
df9e3bd7e2 | ||
|
|
643de08882 | ||
|
|
a252aae862 | ||
|
|
2038943c3e | ||
|
|
e4ec060ac7 | ||
|
|
ae8ab366a8 | ||
|
|
e9ce56e838 | ||
|
|
ee176b76ea | ||
|
|
cf071270a0 | ||
|
|
8bc54a3e27 | ||
|
|
29f4a366c2 | ||
|
|
7ec760bca2 | ||
|
|
22f7a78503 | ||
|
|
3ff6a8b502 | ||
|
|
24cbc8961e | ||
|
|
547f4ee27d | ||
|
|
47dbf87e33 | ||
|
|
15378cfba7 | ||
|
|
539a31103b | ||
|
|
dfcdf234c5 | ||
|
|
5bc9578634 | ||
|
|
3ca48ff230 | ||
|
|
79777d0f78 | ||
|
|
d2036f2a63 | ||
|
|
6c0024e43b | ||
|
|
a2e5a20f52 | ||
|
|
845479d7cd | ||
|
|
2ebcb18097 | ||
|
|
00e54d46d7 | ||
|
|
933370df7c | ||
|
|
437c524b81 | ||
|
|
dc96d7c2dd | ||
|
|
f9247342d1 | ||
|
|
b1d875410a | ||
|
|
89e15c7bcd | ||
|
|
a21da33d48 | ||
|
|
b53b0e7e2c | ||
|
|
48dd41db91 | ||
|
|
16331cdfdc | ||
|
|
ab3722d3b3 | ||
|
|
66c404fd39 | ||
|
|
3ad9d42be9 | ||
|
|
083515da1b | ||
|
|
ac5d257a1c | ||
|
|
896bd88380 | ||
|
|
24f50c7c86 | ||
|
|
e0bf87c2e3 | ||
|
|
ef1e98f867 | ||
|
|
e3db481491 | ||
|
|
21b4925892 | ||
|
|
098c6d3ec7 | ||
|
|
0fdfecfa29 | ||
|
|
97f0f4813c | ||
|
|
8d3f81c2c0 | ||
|
|
8f10d3192c | ||
|
|
1e3439429f | ||
|
|
e9e8c7c901 | ||
|
|
db35f2b0cb | ||
|
|
f90d3bfcc0 | ||
|
|
37c47f8f95 | ||
|
|
a45e467ddd | ||
|
|
d3f9a1855d | ||
|
|
c97cea21cf | ||
|
|
2f05e70734 | ||
|
|
5ab9d6c296 | ||
|
|
e61c5ee5bf | ||
|
|
b5f812270e | ||
|
|
0786408109 | ||
|
|
a18eeeef5a | ||
|
|
5b6dfc70f4 | ||
|
|
59c28971cb | ||
|
|
035c950ff2 | ||
|
|
5a79776625 | ||
|
|
b9cdca78b0 | ||
|
|
fde561075d | ||
|
|
3cce15fa81 | ||
|
|
889fa09ea4 | ||
|
|
3a25b6e1f8 | ||
|
|
22992da15d | ||
|
|
b6247f837d | ||
|
|
9c5b7e14e1 | ||
|
|
c0873728e2 | ||
|
|
86ba54b06e | ||
|
|
af91a7e660 | ||
|
|
d45767bb2f | ||
|
|
d108487396 | ||
|
|
027ea030fa | ||
|
|
6eb9eb53de | ||
|
|
eed6be0f4a | ||
|
|
51900367a0 | ||
|
|
77596f37a3 | ||
|
|
56f842c3af | ||
|
|
075b61901c | ||
|
|
0270027bf9 | ||
|
|
835bf22b7d | ||
|
|
e85b7da63b | ||
|
|
c112df0c1c | ||
|
|
8e791896f1 | ||
|
|
b07a101caf | ||
|
|
72effa1fb1 | ||
|
|
e53ac973e9 | ||
|
|
f1f18bf2bd | ||
|
|
c41c968086 | ||
|
|
939475cb63 | ||
|
|
5e24deb0f1 | ||
|
|
76a0a726f0 | ||
|
|
df821c8a4a | ||
|
|
f40269caec | ||
|
|
2149d8f3e7 | ||
|
|
e65c8c8961 | ||
|
|
ea8e0def6e | ||
|
|
f8ac498d64 | ||
|
|
276422a85b | ||
|
|
28ef6e9e27 | ||
|
|
49caf17ff5 | ||
|
|
cac09a4e20 | ||
|
|
29b4c02709 | ||
|
|
c3d3194bc8 | ||
|
|
d0463fb510 | ||
|
|
6bf054b982 | ||
|
|
fbbf28d21c | ||
|
|
fb89990e2b | ||
|
|
d0a3784be2 | ||
|
|
555ed4963a | ||
|
|
edb6d3b7d3 | ||
|
|
8928e6f972 | ||
|
|
2d8c8f6d75 | ||
|
|
9194e15072 | ||
|
|
77578a882f | ||
|
|
3f9081d00e | ||
|
|
7842001c53 | ||
|
|
09e749bc1a | ||
|
|
f339d0aab7 | ||
|
|
43fccb843c | ||
|
|
f550f1c6b2 | ||
|
|
71f47c3291 | ||
|
|
dc703d1511 | ||
|
|
d70135bf08 | ||
|
|
ea8692b08f | ||
|
|
ff0f43ea2f | ||
|
|
2b2517e4a5 | ||
|
|
b7a4a1a6b0 | ||
|
|
c1a9ad2abc | ||
|
|
c93f77ac19 | ||
|
|
75f3f52589 | ||
|
|
900a2da3e3 | ||
|
|
b7f3664839 | ||
|
|
9511074f40 | ||
|
|
9651a8b2da | ||
|
|
3cf75580b5 | ||
|
|
32c66a98c1 | ||
|
|
62437f8964 | ||
|
|
ac4d156cb7 | ||
|
|
95e69276c3 | ||
|
|
56b285d3e8 | ||
|
|
5cc401683c | ||
|
|
79ce72a7b7 | ||
|
|
a22fdd015b | ||
|
|
f40fa6e8e0 | ||
|
|
b015576a13 | ||
|
|
e4fb4cc793 | ||
|
|
1de9a903c8 | ||
|
|
0bb6584437 | ||
|
|
c0bc0d4826 | ||
|
|
efac1b4856 | ||
|
|
bc77afa6bb | ||
|
|
e4373768af | ||
|
|
2cb2e27cdf | ||
|
|
0f4e631cdd | ||
|
|
dcf6e86d02 | ||
|
|
98ca97fc5b | ||
|
|
802c72e4ed | ||
|
|
eb717cc514 | ||
|
|
db67a0e55e | ||
|
|
34277ef60f | ||
|
|
a3b51a9d9a | ||
|
|
e5608f7b00 | ||
|
|
5a18eb5f4a | ||
|
|
138a1e1c4b | ||
|
|
f1fbd91f0d | ||
|
|
5ddc3ff312 | ||
|
|
b5908b486e | ||
|
|
aa57bbcd44 | ||
|
|
67a585600d | ||
|
|
1caf728a4b | ||
|
|
02816c0f7a | ||
|
|
7bf63b0ac5 | ||
|
|
426db57d3f | ||
|
|
1d995d2bef | ||
|
|
92225cd528 | ||
|
|
35e4764d35 | ||
|
|
b7b9f2a3da | ||
|
|
68183ee441 | ||
|
|
cff5ed46b7 | ||
|
|
7451e7edf4 | ||
|
|
529c156f64 | ||
|
|
47df3a4d60 | ||
|
|
2d2e831659 | ||
|
|
d3ebaf72eb | ||
|
|
183b9680c1 | ||
|
|
74baa53787 | ||
|
|
332aa75afd | ||
|
|
9e2d1164f0 | ||
|
|
48b08c74ab | ||
|
|
260be1c3e6 | ||
|
|
224a7a66a5 | ||
|
|
5a6d082e8c | ||
|
|
7b5f7f84ac | ||
|
|
3508be1476 | ||
|
|
7c5d11e55d | ||
|
|
3dcaa7a996 | ||
|
|
848249cf54 | ||
|
|
692fc3e1ab | ||
|
|
ce8d52ed0b | ||
|
|
f5153a490c | ||
|
|
8815f950b0 | ||
|
|
c948f3a61c | ||
|
|
91877e3421 | ||
|
|
77b89ff182 | ||
|
|
e7ca330e1f | ||
|
|
b4e90e5fce | ||
|
|
e2220df5e8 | ||
|
|
0ec446ab75 | ||
|
|
3c0496d1d3 | ||
|
|
97a111e70a | ||
|
|
eb4f16431c | ||
|
|
c4fd31aa7e | ||
|
|
09d5360bce | ||
|
|
b4d7a32abf | ||
|
|
6aa4f135c9 | ||
|
|
4915f69857 | ||
|
|
843e408556 | ||
|
|
4ef2fa2dff | ||
|
|
8288042eb7 | ||
|
|
3bbae41037 | ||
|
|
77f3963d83 | ||
|
|
49a28f8537 | ||
|
|
c770f7947d | ||
|
|
4b8e7f11cf | ||
|
|
0d2686b5eb | ||
|
|
839be0eb64 | ||
|
|
dc144a4e7d | ||
|
|
bfc42e4f4f | ||
|
|
c4176107f2 | ||
|
|
a3794ea156 | ||
|
|
e8cb2ee470 | ||
|
|
07b3c5e77a | ||
|
|
f3100d1116 | ||
|
|
64a96a13f7 | ||
|
|
f1fb73e12c | ||
|
|
d2faada61b | ||
|
|
f436d1dace | ||
|
|
12ff1cfbc4 | ||
|
|
7bf0de0ff5 | ||
|
|
59b13d091d | ||
|
|
ce72a366bf | ||
|
|
d9876e5296 | ||
|
|
62e67578eb | ||
|
|
0cf7b2344f | ||
|
|
aa5d263c08 | ||
|
|
769a8d6f6a | ||
|
|
3793175894 | ||
|
|
b70f246622 | ||
|
|
689e61081d | ||
|
|
c83f48b821 | ||
|
|
70500c21a8 |
@@ -27,3 +27,5 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
*.test
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/config
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -110,7 +110,7 @@ func Check(root string) (map[string][]string, error) {
|
||||
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
|
||||
// (issue 735), so they stand in it too.
|
||||
var seed = map[string]bool{
|
||||
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
||||
"fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
||||
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
|
||||
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules not yet moved gains none
|
||||
// the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by
|
||||
// TestTheCatalogueSaysConfigInEveryMovedModule. Skipped, and said, where no trunk is beside.
|
||||
func TestTheUnmovedListHoldsNoModuleTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/config/unmoved")
|
||||
found, err := Check(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmoved, err := Unmoved("unmoved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if ok {
|
||||
if base, err = Unmoved(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, _, added := Judge(found, unmoved, base)
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/config/unmoved gains %s, which the trunk's copy does not hold: the list only shrinks", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module checks/health
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package health is the catalogue's count of long-running resources that do not say how they are ready (novox/hq ADR 0240 rule
|
||||
// 8), kept in health-undeclared and judged by a test over the real tree (ADR 0529 clauses 2 and 4): it replaces the step of
|
||||
// merge-check.sh that read it out of the controller's `module check`. Every manifest is read through mesh-sdk's catalogue
|
||||
// package, the one the controller counts with.
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Count is the long-running resources without `health` of every module under root/modules.
|
||||
func Count(root string) (int, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
if len(manifests) == 0 {
|
||||
return 0, fmt.Errorf("no manifest under %s: the count would be zero for any catalogue", filepath.Join(root, "modules"))
|
||||
}
|
||||
n := 0
|
||||
for _, path := range manifests {
|
||||
m, err := catalogue.ReadTreeManifest(root, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
n += len(catalogue.Undeclared(m))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Kept is the number the file keeps: the first number of its first line.
|
||||
func Kept(path string) (int, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
first, _, _ := strings.Cut(string(raw), "\n")
|
||||
digits := ""
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
digits += string(r)
|
||||
}
|
||||
n, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s does not begin with the number it keeps: %q", path, first)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Judge says what is wrong with a count against the kept number; empty when they agree. It only goes down: a rise is refused,
|
||||
// and a fall asks the new number written in health-undeclared, so the count cannot rise again.
|
||||
func Judge(kept, counted int) string {
|
||||
switch {
|
||||
case counted > kept:
|
||||
return fmt.Sprintf("the long-running resources without health rose from %d to %d: declare how each new one is ready (ADR 0240 rule 8)", kept, counted)
|
||||
case counted < kept:
|
||||
return fmt.Sprintf("the long-running resources without health fell from %d to %d: write %d in health-undeclared, so the count cannot rise again", kept, counted, counted)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aCatalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for module, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A service that stays up and does not say how it is ready is counted; one that says, and a file, are not.
|
||||
func TestOnlyALongRunningResourceWithoutHealthIsCounted(t *testing.T) {
|
||||
root := aCatalogue(t, map[string]string{
|
||||
"quiet": `{"module":"quiet","resources":[{"id":"svc","type":"service","unit":"quiet.service","state":"running"}]}`,
|
||||
"says": `{"module":"says","resources":[{"id":"svc","type":"service","unit":"says.service","state":"running",` +
|
||||
`"health":{"kind":"unit"}}]}`,
|
||||
"file": `{"module":"file","resources":[{"id":"cfg","type":"file","path":"/etc/file.conf","content":"x"}]}`,
|
||||
})
|
||||
counted, err := Count(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted != 1 {
|
||||
t.Fatalf("counted %d, wanted the one quiet service", counted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCountMayOnlyGoDown(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
kept, counted int
|
||||
says string
|
||||
}{
|
||||
{58, 58, ""},
|
||||
{58, 59, "rose from 58 to 59"},
|
||||
{58, 57, "fell from 58 to 57"},
|
||||
} {
|
||||
got := Judge(c.kept, c.counted)
|
||||
if (c.says == "") != (got == "") || !strings.Contains(got, c.says) {
|
||||
t.Errorf("kept %d counted %d: %q, wanted %q", c.kept, c.counted, got, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeptCountIsTheFirstNumberOfTheFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "health-undeclared")
|
||||
if err := os.WriteFile(path, []byte("58\nThe long-running resources ...\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := Kept(path); err != nil || n != 58 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("many\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Kept(path); err == nil {
|
||||
t.Fatal("a file with no number gave a count")
|
||||
}
|
||||
}
|
||||
|
||||
// The script's `module check` run over the real tree (novox/hq ADR 0240 rule 8, ADR 0529 clause 2 and 4): the long-running
|
||||
// resources that do not say how they are ready, counted as the controller counts them, are the number health-undeclared keeps.
|
||||
func TestTheCatalogueSaysHowItsLongRunningResourcesAreReady(t *testing.T) {
|
||||
kept, err := Kept(filepath.Join("..", "..", "health-undeclared"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted, err := Count(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if say := Judge(kept, counted); say != "" {
|
||||
t.Fatal(say)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/imagebuild
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,92 @@
|
||||
// Command imagebuild refuses any manifest but modules/buildah providing the rootless-image-build provision
|
||||
// (novox/hq ADR 0487 §3, issue 1704): the image builder's tool is buildah alone, and docker, whose daemon holds
|
||||
// the machine's root, never stands in for it. A second provider would let a node compose mesh-build-image over
|
||||
// something that is not the rootless builder it was designed against.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Provision is the provision only Provider may provide.
|
||||
const Provision = "rootless-image-build"
|
||||
|
||||
// Provider is the one module allowed to provide it.
|
||||
const Provider = "buildah"
|
||||
|
||||
// Finding is one manifest refused: a module other than buildah providing the provision.
|
||||
type Finding struct {
|
||||
Module string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which ones it refuses, and whether buildah provides.
|
||||
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
|
||||
provided := false
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Provides []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"provides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, p := range m.Provides {
|
||||
if p.Name != Provision {
|
||||
continue
|
||||
}
|
||||
if m.Module == Provider {
|
||||
provided = true
|
||||
} else {
|
||||
out = append(out, Finding{Module: m.Module})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !provided {
|
||||
out = append(out, Finding{Module: Provider})
|
||||
}
|
||||
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 {
|
||||
if f.Module == Provider {
|
||||
fmt.Printf("%s does not provide %s (novox/hq ADR 0487 §3)\n", Provider, Provision)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s provides %s: only %s may, and docker never (novox/hq ADR 0487 §3)\n", f.Module, Provision, Provider)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("imagebuild: rootless-image-build is provided only by buildah")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func catalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, body := range manifests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const buildah = `{"module":"buildah","provides":[{"name":"rootless-image-build","reach":"machine"}]}`
|
||||
|
||||
func TestOnlyBuildahProvidesTheRootlessImageBuild(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{"buildah": buildah, "go": `{"module":"go","provides":[{"name":"go-toolchain"}]}`})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("found %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerAndAnySecondProviderAreRefused(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"buildah": buildah,
|
||||
"docker": `{"module":"docker","provides":[{"name":"rootless-image-build"}]}`,
|
||||
"other": `{"module":"other","provides":[{"name":"x"},{"name":"rootless-image-build"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "docker"}, {Module: "other"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildahMustProvideIt(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{"buildah": `{"module":"buildah"}`})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []Finding{{Module: "buildah"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesTheProvisionToBuildahAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue's providers of rootless-image-build are wrong: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/imageplace
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,91 @@
|
||||
// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory
|
||||
// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so
|
||||
// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it.
|
||||
type Finding struct {
|
||||
Module, Artifact, From, Why string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which images 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"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != "image" {
|
||||
continue
|
||||
}
|
||||
f := Finding{Module: m.Module, Artifact: a.Name, From: a.From}
|
||||
clean := filepath.Clean(a.From)
|
||||
switch {
|
||||
case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)):
|
||||
f.Why = "is not a path inside the module's own directory"
|
||||
default:
|
||||
info, err := os.Stat(filepath.Join(dir, clean))
|
||||
if err != nil || info.IsDir() {
|
||||
f.Why = "is not a file in the module's directory"
|
||||
}
|
||||
}
|
||||
if f.Why != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
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: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// module writes modules/<name>/module.json with one image artifact from `from`, and the files named.
|
||||
func module(t *testing.T, root, name, from string, files ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "own", "Dockerfile", "Dockerfile")
|
||||
module(t, root, "nested", "image/Dockerfile", "image/Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("found %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "other", "Dockerfile", "Dockerfile")
|
||||
module(t, root, "leaves", "../other/Dockerfile")
|
||||
module(t, root, "absolute", "/etc/Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mods []string
|
||||
for _, f := range got {
|
||||
mods = append(mods, f.Module)
|
||||
}
|
||||
if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) {
|
||||
t.Errorf("refused %v (%v), want %v", mods, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingDockerfileFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "gone", "Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Module != "gone" {
|
||||
t.Errorf("found %v, want gone refused", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("an image builds from outside its module: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/locks
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,105 @@
|
||||
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
|
||||
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
|
||||
// from its committed lock, so such a module is refused by the builder and could not be delivered.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
|
||||
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
|
||||
type Finding struct {
|
||||
Module string
|
||||
Packages []string
|
||||
}
|
||||
|
||||
// Check reads every module under root/modules that builds a TypeScript artifact.
|
||||
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"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Language string `json:"language"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
typescript := false
|
||||
for _, a := range m.Build.Artifacts {
|
||||
typescript = typescript || a.Language == "typescript"
|
||||
}
|
||||
if !typescript {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pkg struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
|
||||
}
|
||||
var names []string
|
||||
for n := range pkg.Dependencies {
|
||||
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
|
||||
sort.Strings(names)
|
||||
out = append(out, Finding{Module: m.Module, Packages: names})
|
||||
}
|
||||
}
|
||||
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 names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
|
||||
func module(t *testing.T, root, name, language, pkg string, lock bool) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(file, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
|
||||
if pkg != "" {
|
||||
write("package.json", pkg)
|
||||
}
|
||||
if lock {
|
||||
write("package-lock.json", "{}")
|
||||
}
|
||||
}
|
||||
|
||||
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
|
||||
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
|
||||
|
||||
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "plain", "typescript", sdkOnly, false)
|
||||
module(t, root, "none", "typescript", "", false)
|
||||
got, err := Check(root)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "db", "typescript", withPg, false)
|
||||
module(t, root, "db-locked", "typescript", withPg, true)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "go", "go", withPg, false)
|
||||
if got, err := Check(root); err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every TypeScript module that names packages
|
||||
// beyond the SDK commits its lock.
|
||||
func TestTheCatalogueCommitsTheLocksItNeeds(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s names packages beyond the SDK (%v) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)", f.Module, f.Packages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module checks/noscripts
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,19 @@
|
||||
package noscripts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The catalogue's checks are Go tests (novox/hq ADR 0529 clause 5, ADR 0445): the checker runs every nested Go module's tests by
|
||||
// the language's convention and no script at the root says what else to run. A script back at the root would be a check nobody
|
||||
// runs (ADR 0503 §3: since R4e the checker has not run them), so it is refused.
|
||||
func TestNoScriptOfTheOldCheckStandsAtTheRoot(t *testing.T) {
|
||||
for _, name := range []string{"merge-check.sh", "merge-check-typescript.sh", "check-nested-modules.sh"} {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", name)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s stands at the catalogue's root (%v): what it ran is a Go test in checks/ or a step of the build module", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/owned
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
+181
-13
@@ -11,13 +11,23 @@
|
||||
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
|
||||
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
// 5. a directory that is, lies inside or holds a directory a system package owns under /var/lib, or any other
|
||||
// resource inside one (issue 1841: pacman's state directory, placed with "place": ".", was /var/lib/pacman, the
|
||||
// package manager's own database directory). A placed directory is resolved as the mesh resolves it with the
|
||||
// default data root: "." is /var/lib/<module>, "mesh" /var/lib/mesh/<module>, neither /var/lib/<module>/<id>.
|
||||
// The system directories are checks/owned/system, captured from a machine (pacman -Qo, /usr/lib/tmpfiles.d);
|
||||
// checks/owned/system-known names the exceptions still to fix, by module, resource and issue. That list only
|
||||
// shrinks: an entry that no longer collides is refused, and with -base one the trunk's copy does not hold.
|
||||
//
|
||||
// go run . [-base <trunk's system-known>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -27,6 +37,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The lists of rule 5, relative to the catalogue's root.
|
||||
const (
|
||||
SystemList = "checks/owned/system"
|
||||
SystemKnown = "checks/owned/system-known"
|
||||
)
|
||||
|
||||
// Home is the one root both machine homes are read as.
|
||||
const Home = "${home}"
|
||||
|
||||
@@ -43,9 +59,9 @@ type Finding struct {
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID, Type, Path, Unknown string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
ID, Type, Path, Unknown, Place string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
}
|
||||
|
||||
type parsed struct {
|
||||
@@ -65,6 +81,7 @@ func load(file string) (*parsed, error) {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Place string `json:"place"`
|
||||
Unknown *string `json:"unknown"`
|
||||
Knows []string `json:"knows"`
|
||||
} `json:"resources"`
|
||||
@@ -81,7 +98,7 @@ func load(file string) (*parsed, error) {
|
||||
out.Module = filepath.Base(filepath.Dir(file))
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Place: r.Place, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
if r.Unknown != nil {
|
||||
x.Unknown = *r.Unknown
|
||||
if x.Unknown == "" {
|
||||
@@ -143,14 +160,23 @@ type declared struct {
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
func Check(root string, base []string) ([]Finding, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
system, err := readSystem(filepath.Join(root, SystemList))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, err := readLines(filepath.Join(root, SystemKnown))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
var out []Finding
|
||||
var paths []declared
|
||||
collided := map[string]bool{}
|
||||
type container struct{ module, path string }
|
||||
var containers []container
|
||||
for _, file := range files {
|
||||
@@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) {
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
out = append(out, inSystem(m, system, known, collided)...)
|
||||
for _, r := range m.Resources {
|
||||
p := norm(r.Path, dirs)
|
||||
if p != "" {
|
||||
@@ -212,6 +239,28 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range known {
|
||||
if !collided[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is listed in %s, and no longer lies in a system "+
|
||||
"package's directory: take the line out, so the list only shrinks", SystemKnown)})
|
||||
}
|
||||
}
|
||||
if base != nil {
|
||||
held := map[string]bool{}
|
||||
for _, b := range base {
|
||||
held[b] = true
|
||||
}
|
||||
for _, k := range known {
|
||||
if !held[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is a new line in %s, which the trunk's copy "+
|
||||
"does not hold: a module's directory is placed outside a system package's, never listed", SystemKnown)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range paths {
|
||||
for _, c := range containers {
|
||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
||||
@@ -222,21 +271,140 @@ func Check(root string) ([]Finding, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
// systemDir is one directory a system package owns, and what says so.
|
||||
type systemDir struct{ Path, Source string }
|
||||
|
||||
// readLines is a list's lines, without comments and blank lines.
|
||||
func readLines(file string) ([]string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := Check(root)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// readSystem is the list of system directories: "<absolute path> <package>: <how it was read>" per line. Absent or
|
||||
// empty, the check fails: a list that says nothing would let every module through.
|
||||
func readSystem(file string) ([]systemDir, error) {
|
||||
lines, err := readLines(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule 5 reads the system packages' directories from %s: %w", file, err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("%s names no system package's directory, so rule 5 would pass everything", file)
|
||||
}
|
||||
var out []systemDir
|
||||
for _, line := range lines {
|
||||
p, source, _ := strings.Cut(line, " ")
|
||||
if !strings.HasPrefix(p, "/var/lib/") || strings.TrimSpace(source) == "" {
|
||||
return nil, fmt.Errorf("%s: %q is not \"/var/lib/<directory> <package>: <how it was read>\"", file, line)
|
||||
}
|
||||
out = append(out, systemDir{path.Clean(p), strings.TrimSpace(source)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// placedAt is where the mesh places a pathless directory with the default data root (mesh-sdk catalogue dirsFor).
|
||||
func placedAt(module string, r resource) string {
|
||||
switch r.Place {
|
||||
case ".":
|
||||
return "/var/lib/" + module
|
||||
case "mesh":
|
||||
return "/var/lib/mesh/" + module
|
||||
}
|
||||
return "/var/lib/" + module + "/" + r.ID
|
||||
}
|
||||
|
||||
// inSystem is rule 5 for one module: each resource whose resolved path collides with a system package's directory,
|
||||
// unless system-known lists it (then it is marked collided, so a stale line is found).
|
||||
func inSystem(m *parsed, system []systemDir, known []string, collided map[string]bool) []Finding {
|
||||
real := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type == "directory" && r.Path == "" && r.ID != "" {
|
||||
real[r.ID] = placedAt(m.Module, r)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != "" && r.Path != "" {
|
||||
real[r.ID] = r.Path
|
||||
}
|
||||
}
|
||||
var out []Finding
|
||||
for _, r := range m.Resources {
|
||||
p := ""
|
||||
if r.Path != "" {
|
||||
p = norm(r.Path, real)
|
||||
} else if r.Type == "directory" {
|
||||
p = real[r.ID]
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
continue
|
||||
}
|
||||
for _, s := range system {
|
||||
inside := p == s.Path || strings.HasPrefix(p, s.Path+"/")
|
||||
holds := r.Type == "directory" && strings.HasPrefix(s.Path, p+"/")
|
||||
if !inside && !holds {
|
||||
continue
|
||||
}
|
||||
listed := ""
|
||||
for _, k := range known {
|
||||
if strings.HasPrefix(k, m.Module+" "+r.ID+" ") || k == m.Module+" "+r.ID {
|
||||
listed = k
|
||||
}
|
||||
}
|
||||
if listed != "" {
|
||||
collided[listed] = true
|
||||
break
|
||||
}
|
||||
how := "is or lies inside"
|
||||
if holds {
|
||||
how = "holds"
|
||||
}
|
||||
out = append(out, Finding{5, m.Module, r.ID, fmt.Sprintf("resolves to %q, which %s %s, the directory of the "+
|
||||
"system package %s; place it under /var/lib/mesh/%s (\"place\": \"mesh\") or a path of its own", p, how,
|
||||
s.Path, s.Source, m.Module)})
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
baseFile := flag.String("base", "", "the trunk's copy of "+SystemKnown+": a line it does not hold is refused")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
var base []string
|
||||
if *baseFile != "" {
|
||||
lines, err := readLines(*baseFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base = append([]string{}, lines...)
|
||||
}
|
||||
found, err := Check(root, base)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
|
||||
cite := "novox/hq ADR 0422 §8, issue 1024"
|
||||
if f.Rule == 5 {
|
||||
cite = "issue 1841"
|
||||
}
|
||||
fmt.Printf("%s: %s: %s (rule %d, %s)\n", f.Module, f.ID, f.Why, f.Rule, cite)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory, or in a system package's")
|
||||
}
|
||||
|
||||
+102
-1
@@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) {
|
||||
|
||||
func check(t *testing.T, root string) []Finding {
|
||||
t.Helper()
|
||||
found, err := Check(root)
|
||||
if _, err := os.Stat(filepath.Join(root, SystemList)); os.IsNotExist(err) {
|
||||
lists(t, root, "/var/lib/pacman pacman: pacman -Qo\n", "")
|
||||
}
|
||||
found, err := Check(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,6 +38,20 @@ func only(t *testing.T, found []Finding, rule int, module, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// lists writes the catalogue's list of system directories and its known exceptions.
|
||||
func lists(t *testing.T, root, system, known string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte(system), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemKnown), []byte(known), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func dir(id, path, rest string) string {
|
||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||
}
|
||||
@@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a module's directory that resolves to a /var/lib directory a system package owns, lies inside one or
|
||||
// holds one, is refused, whether placed (place ".", or by default beneath the module's own) or stated; a placed
|
||||
// directory saying place "mesh" lies under /var/lib/mesh and passes (issue 1841: pacman's state was /var/lib/pacman).
|
||||
func TestADirectoryOfASystemPackageIsRefused(t *testing.T) {
|
||||
system := "/var/lib/pacman pacman: pacman -Qo\n/var/lib/systemd/coredump systemd: tmpfiles.d/systemd.conf\n"
|
||||
for name, resources := range map[string][]string{
|
||||
"placed-own": {`{"id":"state","type":"directory","place":".","unknown":"program"}`},
|
||||
"placed-beneath": {`{"id":"coredump","type":"directory","unknown":"program"}`},
|
||||
"stated-inside": {dir("d", "/var/lib/pacman/keeper", `,"unknown":"program"`)},
|
||||
"stated-holding": {dir("d", "/var/lib/systemd", `,"unknown":"program"`)},
|
||||
"file-inside": {`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"/var/lib/pacman/keeper.json"}`},
|
||||
} {
|
||||
module := map[string]string{"placed-own": "pacman", "placed-beneath": "systemd"}[name]
|
||||
if module == "" {
|
||||
module = "keeper"
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, module, mod(module, resources...))
|
||||
found := check(t, root)
|
||||
if len(found) != 1 || found[0].Rule != 5 || found[0].Module != module ||
|
||||
!strings.Contains(found[0].Why, "/var/lib/") || !strings.Contains(found[0].Why, "package") {
|
||||
t.Errorf("%s: found %+v; want one finding of rule 5 for %s naming the path and the package", name, found, module)
|
||||
}
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, "pacman", mod("pacman",
|
||||
`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"${dir:state}/keeper.json"}`,
|
||||
dir("other", "/var/lib/pacman-other", `,"unknown":"program"`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a directory under /var/lib/mesh/pacman was refused: %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a known exception passes; one that no longer collides is refused, so the list only shrinks; one the trunk's
|
||||
// copy does not hold is refused; and an absent list of system directories fails the check rather than passing it.
|
||||
func TestKnownSystemExceptionsOnlyShrink(t *testing.T) {
|
||||
system := "/var/lib/systemd systemd: tmpfiles.d/systemd.conf\n"
|
||||
known := "# comment\nsystemd state issue 1873\n"
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, known)
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":".","unknown":"program"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a known exception was refused: %+v", found)
|
||||
}
|
||||
if found, err := Check(root, []string{"systemd state issue 1873"}); err != nil || len(found) != 0 {
|
||||
t.Fatalf("a known exception held by the trunk was refused: %+v %v", found, err)
|
||||
}
|
||||
if found, err := Check(root, []string{}); err != nil || len(found) != 1 || found[0].Rule != 5 {
|
||||
t.Fatalf("an exception the trunk does not hold passed: %+v %v", found, err)
|
||||
}
|
||||
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`))
|
||||
only(t, check(t, root), 5, "systemd", "state")
|
||||
|
||||
root = t.TempDir()
|
||||
manifest(t, root, "a", mod("a"))
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte("# nothing\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Check(root, nil); err == nil {
|
||||
t.Fatal("an empty list of system directories passed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: the catalogue as it stands passes rule 5 with its own lists.
|
||||
func TestTheCatalogueKeepsOutOfSystemDirectories(t *testing.T) {
|
||||
found, err := Check("../..", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Rule == 5 {
|
||||
t.Errorf("%s: %s: %s", f.Module, f.ID, f.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every rule holds.
|
||||
func TestTheCatalogueKeepsEveryRuleOfOwnedPaths(t *testing.T) {
|
||||
found, err := Check("../..", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8)", f.Module, f.ID, f.Why, f.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
// And the list of system directories' known exceptions holds no line the trunk's copy at the merge base does not (rule 5). A trunk
|
||||
// with none had no list: its first copy. Skipped, and said, where no trunk is beside.
|
||||
func TestTheSystemListHoldsNoLineTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/owned/system-known")
|
||||
var base []string
|
||||
if ok {
|
||||
lines, err := readLines(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base = append([]string{}, lines...)
|
||||
}
|
||||
found, err := Check("../..", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Rule == 5 {
|
||||
t.Errorf("%s: %s: %s (rule %d, issue 1841)", f.Module, f.ID, f.Why, f.Rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# The directories under /var/lib that a system package owns (rule 5 of checks/owned, issue 1841). A module's
|
||||
# directory, placed or stated, may not be one of these, lie inside one or hold one: the package's program and the
|
||||
# package manager keep their own files there, and the node-engine would judge, chmod or chown the directory.
|
||||
#
|
||||
# One line per directory: "<path> <package>: <how it was read>". Captured on the workstation on 2026-10-22, through
|
||||
# the mesh: pacman's own database by `pacman -Qo` (pacman_owns), the rest from the `d`, `D` and `Q` lines of
|
||||
# /usr/lib/tmpfiles.d (node-reads.search for "/var/lib/"). A directory found to belong to a package is added here.
|
||||
/var/lib/pacman pacman: pacman -Qo /var/lib/pacman/ (pacman 7.1.0)
|
||||
/var/lib/alsa alsa-utils: tmpfiles.d/alsa-utils.conf
|
||||
/var/lib/colord colord: tmpfiles.d/colord.conf
|
||||
/var/lib/containers podman: tmpfiles.d/podman.conf
|
||||
/var/lib/dbus dbus: tmpfiles.d/dbus.conf
|
||||
/var/lib/geoclue geoclue: tmpfiles.d/geoclue.conf
|
||||
/var/lib/lastlog util-linux: tmpfiles.d/lastlog2.conf
|
||||
/var/lib/libuuid util-linux: tmpfiles.d/uuidd.conf
|
||||
/var/lib/machines systemd: tmpfiles.d/systemd-nspawn.conf
|
||||
/var/lib/openvpn/chroot networkmanager-openvpn: tmpfiles.d/nm-openvpn-tmpfiles.conf
|
||||
/var/lib/portables systemd: tmpfiles.d/portables.conf
|
||||
/var/lib/postgres postgresql: tmpfiles.d/postgresql.conf
|
||||
/var/lib/private systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/rpcbind rpcbind: tmpfiles.d/rpcbind.conf
|
||||
/var/lib/systemd systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/tpm2-tss/system/keystore tpm2-tss: tmpfiles.d/tpm2-tss-fapi.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
# Modules whose directory lies in a system package's directory today (rule 5 of checks/owned, issue 1841), each with
|
||||
# the issue that moves it out: "<module> <resource id> issue <number>". This list only shrinks: a line whose module
|
||||
# no longer collides is refused, and so is a line the trunk's copy does not hold.
|
||||
postgres state issue 1873
|
||||
systemd state issue 1873
|
||||
@@ -0,0 +1,3 @@
|
||||
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
|
||||
pacman
|
||||
kernel
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/particulars
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,159 @@
|
||||
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
|
||||
// (testdata/novox/x, /srv/novox/data) is not blanked.
|
||||
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(check(root))
|
||||
}
|
||||
|
||||
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
|
||||
func check(root string) int {
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
}
|
||||
var covered []string
|
||||
if err == nil {
|
||||
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
|
||||
if err == nil && len(covered) == 0 {
|
||||
err = fmt.Errorf("covered lists no module — the check would pass on anything")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
findings, err := run(root, names, covered)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
|
||||
return 0
|
||||
}
|
||||
|
||||
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
|
||||
func readList(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// run reads every text file of each covered module and returns the findings, sorted.
|
||||
func run(root string, names, covered []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, m := range covered {
|
||||
dir := filepath.Join(root, "modules", m)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case "node_modules", "dist", "vendor", ".git":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
// A path is read too: a testdata directory named after a node is a finding.
|
||||
for _, n := range hits(filepath.ToSlash(rel), names) {
|
||||
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
|
||||
}
|
||||
if d.Name() == "go.sum" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.IndexByte(b, 0) >= 0 {
|
||||
return nil
|
||||
}
|
||||
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scan returns a finding for each node name in src, whole-word and in any case.
|
||||
func scan(file, src string, names []string) []string {
|
||||
var out []string
|
||||
for i, line := range strings.Split(src, "\n") {
|
||||
for _, n := range hits(line, names) {
|
||||
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
|
||||
func hits(line string, names []string) []string {
|
||||
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
if rx.MatchString(line) {
|
||||
out = append(out, strings.ToLower(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var names = []string{"novox", "ace", "shanks", "g14"}
|
||||
|
||||
func TestANodeNameIsRefusedWhereItIsWritten(t *testing.T) {
|
||||
cases := map[string]struct{ file, src string }{
|
||||
"manifest string": {"modules/kernel/module.json", "{\n \"description\": \"as on Shanks\"\n}\n"},
|
||||
"file line": {"modules/kernel/files/boot.sh", "#!/bin/sh\necho run on g14\n"},
|
||||
"go identifier": {"modules/kernel/cmd/x/a.go", "package main\n\nfunc novox() {}\n"},
|
||||
"test name": {"modules/kernel/cmd/x/a_test.go", "package main\n\nfunc TestAce(t *testing.T) {}\n\nfunc TestOnAce_x(t *testing.T) {}\n"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
if got := scan(c.file, c.src, names); len(got) == 0 {
|
||||
t.Errorf("%s: %q not refused", name, c.src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFindingNamesTheLineAndTheWord(t *testing.T) {
|
||||
got := scan("f.sh", "a\nb\nrun on ACE now\n", names)
|
||||
if len(got) != 1 || !strings.HasPrefix(got[0], "f.sh:3:") || !strings.Contains(got[0], "ace") {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheForgeIsNotANodeName(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"see novox/hq issue 5",
|
||||
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
|
||||
"https://git.novox.be/novox/mesh-catalog",
|
||||
"git.novox.be",
|
||||
"space trace surface grace g14x",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeNameInAPathOrJoinedIsRefused(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"modules/kernel/testdata/novox/meminfo",
|
||||
"/srv/novox/data",
|
||||
"testdata/shanks-meminfo.txt",
|
||||
"on-shanks",
|
||||
"shanks-desktop",
|
||||
"mesh-novox",
|
||||
"func novoxTree() {}",
|
||||
"aceFiles := 1",
|
||||
"g14Meminfo := 2",
|
||||
"novox_tree := 3",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) == 0 {
|
||||
t.Errorf("%q not refused", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("clean\n"), 0o644)
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
|
||||
t.Errorf("got %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
}
|
||||
write("modules/kernel/a", "on ace\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("finding = %d, want 1", got)
|
||||
}
|
||||
os.Remove(filepath.Join(root, "particulars"))
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("no list = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("modules/kernel/files/a", "on novox\n")
|
||||
write("modules/other/files/a", "on novox\n")
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module.
|
||||
func TestTheCoveredModulesNameNoNode(t *testing.T) {
|
||||
if code := check("../.."); code != 0 {
|
||||
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/paths
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no module names another's state on its machine,
|
||||
// and the known list holds only what still occurs.
|
||||
func TestNoModuleNamesAnothersState(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared, _, err := list("shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, _, err := list("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, gone := Judge(found, shared, known)
|
||||
for _, f := range refused {
|
||||
t.Error(f)
|
||||
}
|
||||
for _, k := range gone {
|
||||
t.Errorf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back", k)
|
||||
}
|
||||
}
|
||||
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped,
|
||||
// and said, where no trunk is beside.
|
||||
func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) {
|
||||
sharedPath, okShared := trunk.Copy(t, "checks/paths/shared")
|
||||
knownPath, okKnown := trunk.Copy(t, "checks/paths/known")
|
||||
if !okShared || !okKnown {
|
||||
t.Log("the trunk holds not both lists: their first copies, to which nothing was added")
|
||||
return
|
||||
}
|
||||
shared, sharedWhy, err := list("shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, knownWhy, err := list("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseShared, _, err := list(sharedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseKnown, _, err := list(knownPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) {
|
||||
t.Error(g)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# Paths every program on a machine reads as the machine's own, not as a message from the module that
|
||||
# writes them (checks/paths, novox/hq ADR 0260). One per line, with why.
|
||||
/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them
|
||||
/var/lib/mesh-build-node-cache # allow: the Go build module gives its integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): predefined.json removes no key a module still
|
||||
// names, judged against the trunk's copy at the merge base. A trunk with none had no keys; with no trunk to read, nothing can be
|
||||
// seen removed, and the test says so.
|
||||
func TestThePredefinedListRemovesNoKeyAModuleStillNames(t *testing.T) {
|
||||
tree, err := Keys(filepath.Join("..", "..", File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, ok := trunk.Copy(t, File)
|
||||
if !ok {
|
||||
t.Log("no trunk copy of " + File + ": no removal can be seen")
|
||||
return
|
||||
}
|
||||
base, err := Keys(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := Named(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed := Removed(base, tree, named)
|
||||
keys := make([]string, 0, len(removed))
|
||||
for k := range removed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
t.Errorf("%q is removed from %s and still named by %v", k, File, removed[k])
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the known list gains no line the trunk's copy
|
||||
// at the merge base does not hold. Without a trunk copy the lines in seed are the trunk's.
|
||||
func TestTheKnownListHoldsNoLineTheTrunksDoesNot(t *testing.T) {
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if path, ok := trunk.Copy(t, "checks/secrets/known"); ok {
|
||||
if base, err = Known(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, line := range Added(known, base) {
|
||||
t.Errorf("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)", line)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules keeping file text inline
|
||||
// gains none the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by
|
||||
// TestTheCatalogueKeepsFileTextInlineOnlyWhereListed. Skipped, and said, where no trunk is beside.
|
||||
func TestTheInlineListHoldsNoModuleTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/templates/inline")
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := Listed(listFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if ok {
|
||||
if base, err = Listed(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, _, added := Judge(found, listed, base)
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/templates/inline gains %s, which the trunk's copy does not hold: the list only shrinks", m)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
|
||||
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
|
||||
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, nodejs and npm only in modules/nodejs, and buildah (issue 1702) only in modules/buildah.
|
||||
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
|
||||
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
|
||||
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
|
||||
@@ -19,9 +19,10 @@ import (
|
||||
|
||||
// Owners names, for each toolchain package, the one module that declares it.
|
||||
var Owners = map[string]string{
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
"buildah": "buildah",
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
}
|
||||
|
||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||
@@ -100,5 +101,5 @@ func main() {
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
|
||||
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
|
||||
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
|
||||
})
|
||||
@@ -39,6 +40,23 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondDeclarerOfBuildahIsRefused(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
"imagery": `{"module":"imagery","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "imagery", Package: "buildah", Owner: "buildah"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[]}`,
|
||||
@@ -47,8 +65,8 @@ func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
|
||||
if len(got) != 4 {
|
||||
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/trunk
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package trunk is where a catalogue check finds the trunk's copy of a list that may only shrink (novox/hq ADR 0529 clause 2):
|
||||
// the file as the catalogue's base commit holds it, to judge a change's list against.
|
||||
//
|
||||
// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where
|
||||
// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With
|
||||
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the comparison is skipped and the test says NOT COMPARED
|
||||
// WITH THE TRUNK, naming why: the checker that runs before R4g's delivery places siblings but not the change's own repository,
|
||||
// so R4g's own check cannot hold the copy (issue 1295 notes that a missing copy becomes a failure once that checker is gone). A trunk that holds no such file had
|
||||
// none: the list's first copy, which nothing was added to.
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Repository is the catalogue's name beside a check.
|
||||
const Repository = "mesh-catalog"
|
||||
|
||||
// BesideEnv is where a merge check says the clones beside the one checked are.
|
||||
const BesideEnv = "MESH_CHECK_BESIDE"
|
||||
|
||||
// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path,
|
||||
// and whether the trunk holds one (false: its first copy). It skips the test, saying so, when no copy of the catalogue is beside.
|
||||
func Copy(t testing.TB, rel string) (string, bool) {
|
||||
t.Helper()
|
||||
raw, found, skip, err := Read(rel)
|
||||
switch {
|
||||
case skip != "":
|
||||
t.Skip(skip)
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case !found:
|
||||
t.Logf("the trunk holds no %s: its first copy", rel)
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), filepath.Base(rel))
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is
|
||||
// named; err is a trunk named and not there, or unreadable.
|
||||
func Read(rel string) (content []byte, found bool, skip string, err error) {
|
||||
beside := os.Getenv(BesideEnv)
|
||||
if beside == "" {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel +
|
||||
" is not beside this run; the checker places it", nil
|
||||
}
|
||||
dir := filepath.Join(beside, Repository)
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + Repository + " is not beside this check in " + BesideEnv + "=" + beside +
|
||||
", so " + rel + " is not judged against the trunk's copy (the checker before R4g places no copy of the change's own repository)", nil
|
||||
} else if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, rel))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
return raw, true, "", nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) {
|
||||
beside := t.TempDir()
|
||||
dir := filepath.Join(beside, Repository, "checks", "x")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "known"), []byte("a\nb\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(BesideEnv, beside)
|
||||
raw, found, skip, err := Read("checks/x/known")
|
||||
if err != nil || skip != "" || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err)
|
||||
}
|
||||
if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, "")
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACloneBesideWithoutTheCatalogueIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not (the checker before R4g)
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" || !strings.Contains(skip, "not beside") {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED naming the missing copy", found, skip, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# The modules of the catalogue whose texts still name the build agent or the toolchain images, and why (checks/words,
|
||||
# buildagent_test.go). One module directory a line, with its reason after a #. It only shrinks.
|
||||
modules/mesh-build-go # build.go:45 says "the build agent's recipe" in a step's description: the asks lane's module (issue 1887), not E6's to edit
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The build agent and the toolchain images are gone (novox/hq ADR 0524 §5, issue 1295 and E6 of milestone R4g): no text a module
|
||||
// can show names them. Where the word stands for a reason that is another lane's or still waits for its change, buildagent-allowed
|
||||
// names the module's directory with that reason and the date it is dated from; the list only shrinks: a directory on it that no
|
||||
// longer names the word is refused, so the line is taken out.
|
||||
var gone = []string{"build-agent", "build agent", "toolchain image"}
|
||||
|
||||
func allowedModules(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("buildagent-allowed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
entry, why, _ := strings.Cut(line, "#")
|
||||
if entry = strings.TrimSpace(entry); entry != "" && !strings.HasPrefix(line, "#") {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
t.Fatalf("%q is allowed without a reason", entry)
|
||||
}
|
||||
out[entry] = strings.TrimSpace(why)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoTextOfTheCatalogueNamesTheBuildAgentOutsideTheModulesAllowed(t *testing.T) {
|
||||
texts, _, err := collect("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := allowedModules(t)
|
||||
seen := map[string]bool{}
|
||||
for _, f := range check(gone, texts) {
|
||||
module := f
|
||||
if rest, ok := strings.CutPrefix(f, "modules/"); ok {
|
||||
module = "modules/" + strings.SplitN(rest, "/", 2)[0]
|
||||
}
|
||||
if _, ok := allowed[module]; ok {
|
||||
seen[module] = true
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s (the build agent and the toolchain images are gone: say what stands in their place)", f)
|
||||
}
|
||||
for module, why := range allowed {
|
||||
if !seen[module] {
|
||||
t.Errorf("buildagent-allowed names %s (%s), which names the word no more: take the line out, so it cannot come back", module, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func checkLines(lines []retiredLine, texts []text) []string {
|
||||
for _, l := range lines {
|
||||
rx := linePattern(l)
|
||||
for _, t := range texts {
|
||||
if !strings.Contains(t.s, l.daemon) { // the pattern holds the daemon's name whole: the cheap test first
|
||||
continue
|
||||
}
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
|
||||
+63
-36
@@ -43,18 +43,63 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(run(root))
|
||||
}
|
||||
|
||||
// run reads every text a module can show under root and answers the command's exit code: 0 clean, 1 a finding, 2 a list or a
|
||||
// file unreadable. The command prints what it found; a test of the real catalogue reads the code.
|
||||
func run(root string) int {
|
||||
words, err := readList(filepath.Join(root, "retired-words"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
if len(words) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
texts, files, err := collect(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
return 2
|
||||
}
|
||||
findings := check(words, texts)
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
|
||||
lines, err := readLines(filepath.Join(root, "retired-lines"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
return 2
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
|
||||
return 2
|
||||
}
|
||||
if findings := checkLines(lines, texts); len(findings) > 0 {
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
|
||||
len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
|
||||
len(texts), files)
|
||||
return 0
|
||||
}
|
||||
|
||||
// collect reads every text a module can show under root: the strings of its own code that is not a test, and every string of
|
||||
// its manifests.
|
||||
func collect(root string) ([]text, int, error) {
|
||||
var texts []text
|
||||
files := 0
|
||||
err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
|
||||
err := filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,38 +132,7 @@ func main() {
|
||||
texts = append(texts, found...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
findings := check(words, texts)
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
|
||||
lines, err := readLines(filepath.Join(root, "retired-lines"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
}
|
||||
if findings := checkLines(lines, texts); len(findings) > 0 {
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
|
||||
len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
|
||||
len(texts), files)
|
||||
return texts, files, err
|
||||
}
|
||||
|
||||
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
|
||||
@@ -152,9 +166,22 @@ func pattern(word string) *regexp.Regexp {
|
||||
|
||||
func check(words []string, texts []text) []string {
|
||||
var out []string
|
||||
// A text can hold a word only if it holds the word's first part, in any case: the regular expression is run on those alone,
|
||||
// which keeps the check of the whole catalogue within the unit level's bound.
|
||||
lower := make([]string, len(texts))
|
||||
for i, t := range texts {
|
||||
lower[i] = strings.ToLower(t.s)
|
||||
}
|
||||
for _, w := range words {
|
||||
rx := pattern(w)
|
||||
for _, t := range texts {
|
||||
first := ""
|
||||
if parts := strings.Fields(w); len(parts) > 0 {
|
||||
first = strings.ToLower(parts[0])
|
||||
}
|
||||
for i, t := range texts {
|
||||
if !strings.Contains(lower[i], first) {
|
||||
continue
|
||||
}
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
|
||||
@@ -87,3 +87,11 @@ func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
|
||||
t.Fatal("a line with no command was read")
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no word the glossary retired, and no
|
||||
// daemon's line, in any text a module's tools can show an agent.
|
||||
func TestTheCatalogueHoldsNoRetiredWordAndNoDaemonsLine(t *testing.T) {
|
||||
if code := run("../.."); code != 0 {
|
||||
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
59
|
||||
58
|
||||
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: checks/health 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
|
||||
resource without `health` is refused.
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in
|
||||
# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media
|
||||
# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it
|
||||
# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in
|
||||
# its walk.
|
||||
#
|
||||
# For every touched module that has a package.json, in a copy of it outside the clone:
|
||||
#
|
||||
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
|
||||
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
|
||||
# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name:
|
||||
# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq
|
||||
# issue 385);
|
||||
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
|
||||
# compiles with — the same check the build makes, before the merge rather than in the walk;
|
||||
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
|
||||
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
|
||||
# the compiled module, or a source that imports its sibling by its .js name, finds it.
|
||||
#
|
||||
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it —
|
||||
# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385).
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# **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
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
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"
|
||||
if [ ! -f "$tsc" ]; then
|
||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in —
|
||||
# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run.
|
||||
touched=$({
|
||||
printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d'
|
||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||
} | sed '/^$/d' | sort -u)
|
||||
|
||||
failed=""
|
||||
for m in $touched; do
|
||||
d="modules/$m"
|
||||
[ -f "$d/package.json" ] || continue
|
||||
copy="$work/$m"
|
||||
mkdir -p "$copy"
|
||||
(cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -)
|
||||
# What it depends on, against what the toolchain holds.
|
||||
missing=$(node -e '
|
||||
const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]);
|
||||
const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n;
|
||||
const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) });
|
||||
const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) &&
|
||||
require("fs").existsSync(process.argv[2] + "/" + n));
|
||||
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
|
||||
if [ -n "$missing" ]; then
|
||||
echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)"
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$copy/node_modules"
|
||||
for p in "$deps"/*; do
|
||||
case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac
|
||||
cp -r "$p" "$copy/node_modules/"
|
||||
done
|
||||
sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort)
|
||||
if [ -n "$sources" ]; then
|
||||
echo "type-checking $d (TypeScript)"
|
||||
# shellcheck disable=SC2086 # the module's sources, none with a space in its name
|
||||
if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \
|
||||
--types node $sources); then
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "$d has no TypeScript sources of its own to type-check: its tests read its files"
|
||||
fi
|
||||
if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then
|
||||
echo "NOT TESTED: $d is TypeScript and has no tests of its own"
|
||||
continue
|
||||
fi
|
||||
if [ -n "$sources" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
(cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \
|
||||
--rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \
|
||||
--target ES2022 --types node --rootDir . --outDir . $sources)
|
||||
fi
|
||||
echo "testing $d (TypeScript)"
|
||||
if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \
|
||||
--no-warnings=ExperimentalWarning test/*.test.ts); then
|
||||
failed="$failed $m"
|
||||
fi
|
||||
done
|
||||
if [ -n "$failed" ]; then
|
||||
echo "TypeScript modules that failed:$failed"
|
||||
exit 1
|
||||
fi
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
# mesh-check-also: typescript merge-check-typescript.sh
|
||||
#
|
||||
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
|
||||
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
|
||||
#
|
||||
# The gate — the manifests the change touches through the running controller's module check, every machine
|
||||
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
|
||||
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
|
||||
# graph builds these modules from here. It is not repeated here. This is the repository's own:
|
||||
#
|
||||
# 1. every manifest through the controller's module check, so one module's change cannot leave another
|
||||
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
|
||||
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
|
||||
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
|
||||
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
|
||||
# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build
|
||||
# integration`) run here, with -tags integration, and only here. A module written in
|
||||
# TypeScript is type-checked and tested by
|
||||
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
|
||||
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
|
||||
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
|
||||
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
|
||||
# so a word newly retired is found everywhere it stands. The check's own test runs first.
|
||||
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
|
||||
# that another module's manifest places — modules talk over the bus, not through each other's files.
|
||||
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
|
||||
# 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
|
||||
# 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.
|
||||
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
|
||||
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
||||
# only under a home; and no module declares a path inside a directory of another module.
|
||||
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
# 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 . ../..)
|
||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||
(cd checks/mailstore && go test -count=1 . && go run . ../..)
|
||||
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
|
||||
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
|
||||
(cd checks/sshdir && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
|
||||
(cd checks/owned && 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
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
base_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||
if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 &&
|
||||
git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then
|
||||
base_lists=$(mktemp -d)
|
||||
git show "$base_ref:checks/paths/known" >"$base_lists/known"
|
||||
git show "$base_ref:checks/paths/shared" >"$base_lists/shared"
|
||||
fi
|
||||
(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi)
|
||||
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
|
||||
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
|
||||
# they are ready, as the controller counts them, against the number kept in health-undeclared. A change
|
||||
# that raises it fails; one that lowers it writes the new number there, so it can never rise again.
|
||||
kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1)
|
||||
counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p')
|
||||
if [ -z "$counted" ]; then
|
||||
echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)"
|
||||
elif [ "$counted" -gt "$kept" ]; then
|
||||
echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)"
|
||||
exit 1
|
||||
elif [ "$counted" -lt "$kept" ]; then
|
||||
echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again"
|
||||
exit 1
|
||||
else
|
||||
echo "long-running resources without health: $counted, as kept"
|
||||
fi
|
||||
else
|
||||
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
|
||||
fi
|
||||
|
||||
race=""
|
||||
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
|
||||
|
||||
# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238):
|
||||
# a module by its name, a new one by its directory. By hand, without it, the directories the changed files
|
||||
# are in.
|
||||
if [ -n "${MESH_CHECK_MODULES:-}" ]; then
|
||||
touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u)
|
||||
else
|
||||
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
|
||||
fi
|
||||
# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check
|
||||
# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test
|
||||
# runs first.
|
||||
(cd checks/deps && go test -count=1 .)
|
||||
deps=$(mktemp -d)
|
||||
trap 'rm -rf "$deps"' EXIT
|
||||
(cd checks/deps && go build -o "$deps/deps" .)
|
||||
untestable=""
|
||||
for m in $touched; do
|
||||
[ -d "modules/$m" ] || continue
|
||||
if [ ! -f "modules/$m/go.mod" ]; then
|
||||
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
|
||||
continue
|
||||
fi
|
||||
if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then
|
||||
echo "FAILED: modules/$m could not be tested — $modfile"
|
||||
untestable="$untestable modules/$m"
|
||||
continue
|
||||
fi
|
||||
[ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check"
|
||||
echo "testing modules/$m"
|
||||
unformatted=$(cd "modules/$m" && gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "modules/$m is not gofmt'd: $unformatted"
|
||||
exit 1
|
||||
fi
|
||||
cgo=0
|
||||
[ -n "$race" ] && cgo=1
|
||||
# The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or
|
||||
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
|
||||
done
|
||||
if [ -n "$untestable" ]; then
|
||||
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,360 +0,0 @@
|
||||
# betterbird
|
||||
|
||||
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
|
||||
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316,
|
||||
ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held
|
||||
on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through
|
||||
Betterbird's enterprise policy, and owns Betterbird's whole configuration as settings: the preferences, the
|
||||
mail accounts without their credentials, the identities with their signatures, the CalDAV calendars, the
|
||||
CardDAV address books, the filters and the tags.
|
||||
|
||||
It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a
|
||||
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
|
||||
was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq
|
||||
issue 345.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where | class |
|
||||
|---|---|---|
|
||||
| Betterbird, the upstream release (`archive`, pinned by URL and sha256) | `/opt/betterbird` | owned, whole |
|
||||
| the enterprise policy (`install/distribution/policies.json`) | `/opt/betterbird/distribution` | owned, whole |
|
||||
| Owl's xpi (`owl`, pinned by the vendor's sha256) | `/opt/betterbird/owl` | owned, whole |
|
||||
| the module's defaults (`install/defaults-pref/mesh-defaults.js`: the preferences kept at Betterbird's own default, no value of the operator's) and the spelling dictionaries' default (`vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its two files |
|
||||
| the tools' program, for the start's preparation | `/usr/local/lib/mesh-betterbird` | owned, whole |
|
||||
| the start | `/usr/local/bin/betterbird` | owned, whole |
|
||||
| the launcher entry | `/usr/local/share/applications/eu.betterbird.Betterbird.desktop` | owned, whole |
|
||||
| the module's directory, with the settings as the node-engine writes them (`prefs.json`, `accounts.json`, `configuration.json`, `tags.json`), the records of what was adopted or created, and the filter files it replaced (`kept/`) | `~/.config/betterbird/` | owned, whole |
|
||||
| the profile's `user.js`, placed by the tools | the profile | the module's, or never touched |
|
||||
| the lists in the profile's `prefs.js` (accounts, outgoing servers, an adopted account's identities, the calendars' order), and the preferences of what the module created and the settings no longer hold | the profile | written into: the module's keys only, only while Betterbird is closed |
|
||||
| each declared account's `msgFilterRules.dat` (setting `filters`) | the account's directory in the profile | owned, whole, only while Betterbird is closed |
|
||||
| the profile's own values of the preferences the module manages, in `prefs.js` | the profile | cleared only by `betterbird_config_apply` with apply, Betterbird quit |
|
||||
| the profile: mail, address books, calendars, logins | `~/.thunderbird/` | found (ADR 0182) |
|
||||
|
||||
**The community package `betterbird-bin` is declared absent** (ADR 0180), before anything is placed in
|
||||
`/opt/betterbird`: the node-engine removes it with `pacman -R`, then unpacks the release where the package
|
||||
had it. Betterbird chooses its profile by its installation's path, so the same profile opens after the
|
||||
switch. A package left in place (its removal failed) holds the path, and the release is refused there by
|
||||
the node-engine rather than mixed in. The module creates no link: the start, a launcher entry naming the
|
||||
icon in the installation, and the dictionaries as a default preference replace the package's links.
|
||||
|
||||
**Every new build waits for the operator** (`upgrade: record`): the first push to a node removes the
|
||||
package and replaces what a running Betterbird has open, so the operator quits Betterbird, then pushes.
|
||||
An upgrade is a change to the pins, reviewed and delivered like any other (ADR 0316 §5).
|
||||
|
||||
**The pins.** The release is `betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz` (81,620,684 bytes) and
|
||||
Owl is 1.5.6.1 (430,201 bytes), each pinned by sha256 in `module.json`. The build seat fetches each once,
|
||||
refuses it when the digest differs, keeps it under its sha256, and packs it into the artifact store; a node
|
||||
receives the mesh's archive by digest and never asks the vendor. **Owl is never in this repository**: its
|
||||
licence forbids copying it to third parties, and its vendor serves only its newest version, at one
|
||||
address. Keeping the pinned copy in the mesh's private artifact store for the operator's own machines was
|
||||
the operator's call (2026-10-12, ADR 0316 §6).
|
||||
|
||||
**Owl's range.** Owl declares which Betterbird versions it supports (`strict_min_version` to
|
||||
`strict_max_version`: 115 to 156 for 1.5.6.1). `owlSupports` in `manifest_test.go` holds the range read
|
||||
from each pinned xpi, and the module's tests refuse a Betterbird pin outside it: moving Owl's pin means
|
||||
reading the new range there (ADR 0316 §8).
|
||||
|
||||
**The policy** turns Betterbird's own updater off (`DisableAppUpdate`, which Betterbird's update check
|
||||
honours) and installs Owl into every profile from `file:///opt/betterbird/owl/owl.xpi` as
|
||||
`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy
|
||||
installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a
|
||||
fact by `betterbird_status`; Owl installed by policy cannot be removed in Betterbird, so the remedy is
|
||||
moving the pin. An older one is a finding of `betterbird_check`, with the same remedy.
|
||||
|
||||
**Betterbird never asks to be the default client.** The policy locks `mail.shell.checkDefaultClient` off
|
||||
(`Preferences`, `Status: locked`): Betterbird's own default (`all-thunderbird.js`) is to ask at every start,
|
||||
with a window to make it the default for mail, news, feeds and calendar files, while that preference is true
|
||||
(`messenger.js`). Betterbird 153 has no policy of its own for it (its `policies-schema.json`), and its
|
||||
`Preferences` policy accepts the `mail.` prefix. A lock, not a default: the window's own "always check" box
|
||||
writes the preference into the profile, which wins over a default; a lock wins over the profile and greys the
|
||||
box out in the settings. `betterbird_check` says when Betterbird would ask (novox/hq issue 345). **Owl's licence stays the person's**: the mesh holds no licence, key or payment (ADR 0316 §9).
|
||||
|
||||
**No start of its own.** Betterbird is started by the operator from the launcher. The start
|
||||
(`/usr/local/bin/betterbird`) first runs `betterbird-tools prepare`, which, with no Betterbird of the
|
||||
account running, places the user.js and writes what Betterbird rewrites itself (the lists, the removals, the
|
||||
filter files); then it
|
||||
starts Betterbird either way. `betterbird_restart` starts it as the transient user unit
|
||||
`mesh-betterbird`, so its output then goes to the journal.
|
||||
|
||||
|
||||
## The preferences: locks, the setting prefs, and Betterbird's own defaults
|
||||
|
||||
Betterbird's preferences come in three layers (novox/hq issue 345, issue 586). **The operator's own choices
|
||||
live in the module's configuration, never in the catalogue**: the module ships only its policy's locks and the
|
||||
names it keeps at Betterbird's own default.
|
||||
|
||||
| layer | where | who changes it | holds |
|
||||
|---|---|---|---|
|
||||
| **locked** | `/opt/betterbird/distribution/policies.json` (`install/distribution/`) | only a reviewed catalogue change | what must never differ on a node; Betterbird's settings show it greyed out |
|
||||
| **declared** | the setting `prefs`, mesh-wide with a node's layer over it, written to `~/.config/betterbird/prefs.json` and placed in the profile's `user.js` | the operator, through the settings layers (trusted: the terminal or the operator's warrant) | the operator's setup, and a node's exceptions, such as a font size for a larger screen |
|
||||
| **Betterbird's default** | Betterbird's own defaults; `mesh-defaults.js` names those the module manages there | a catalogue change | the preferences kept at Betterbird's default |
|
||||
|
||||
A locked value wins over everything; a declared one (in `user.js`) wins over the profile's `prefs.js` at every
|
||||
start; `prefs.js` wins over a default.
|
||||
|
||||
**The setting `prefs` is one entry per preference** (issue 586): `{"prefs": {"mail.uifontsize": 14, …}}`, the
|
||||
value true, false, a whole number or a string. The node-engine merges the layers key by key, so a node's layer
|
||||
overrides one preference, or takes one out with `null`. The operator's setup approved on 2026-10-12 (compact
|
||||
density, 14-point text, the vertical view with multi-line cards and threads, no new-mail pop-up, sound or tray
|
||||
icon, the calendar's day from 09:00 to 20:00, reminders missed while asleep shown, chat off, remote content
|
||||
blocked, Do Not Track; 21 preferences) moved from the catalogue's defaults file into the mesh-wide layer of
|
||||
`prefs`, set on the operator's Approve.
|
||||
|
||||
**The order that leaves no node on Betterbird's own defaults** (issue 586): merge this build (the `record`
|
||||
upgrade policy holds it back from the nodes); propose the mesh-wide layer through the controller's `settings`
|
||||
verb with `propose`, carrying the current `accounts` verbatim and `prefs` with the 21 values; the operator
|
||||
approves; only then push the desktop nodes. Caveat: between that approval and a node's push, any other send to a
|
||||
desktop node writes the map's text into the old build's user.js, and `betterbird_check` there says the user.js is
|
||||
refused until the push.
|
||||
|
||||
**Locked** (the policy): Betterbird's updater off (`DisableAppUpdate`); telemetry off (`DisableTelemetry`);
|
||||
add-ons change only through the mesh's pins (`ExtensionUpdate` false); and three preferences locked off:
|
||||
`mail.shell.checkDefaultClient` (the ask to be the default client), `mail.inappnotifications.enabled` (the
|
||||
vendor's in-app notices), and `mailnews.start_page.enabled` (the start page, which otherwise loads from the
|
||||
vendor at every start).
|
||||
|
||||
**Kept at Betterbird's default** (`// keeps Betterbird's default: <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
|
||||
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
|
||||
|
||||
**A user.js the module did not write is never overwritten.** The module's begins with the line
|
||||
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with what to do.
|
||||
|
||||
## The accounts: declared without credentials (ADR 0316 §10–§16, ADR 0317)
|
||||
|
||||
The setting `accounts` reaches the machine as `~/.config/betterbird/accounts.json`, a trusted mergeable
|
||||
file: a map of accounts by name, each with `address`, `from-name` and `label` (optional), `incoming` (`kind`
|
||||
imap or pop3, `host`, `port`, `security` tls, starttls or none, `sign-in` password, encrypted-password,
|
||||
oauth, kerberos, ntlm or tls-certificate, `user`) and `outgoing` (the same, without a kind; its sign-in may
|
||||
be none), and, only in a node's own layer, `key`. **Never a password or a token**: the whole file is
|
||||
refused when any key in it is shaped like one, and an account signs in at its first use, in Betterbird's
|
||||
own window, where Betterbird keeps the result in its login store. The work account through Owl is never
|
||||
declared: the person creates it in Owl's setup (ADR 0316 §13).
|
||||
|
||||
At every apply (the start from the launcher, `betterbird_user_js` with apply, `betterbird_restart`), each
|
||||
declared account, in its place in the setting, is (ADR 0317 §1):
|
||||
|
||||
1. **kept by its recorded keys**, when the module recorded it on this node for this profile;
|
||||
2. **its creation**, when it is under the module's own keys (`account_mesh_<name>`, `id_mesh_<name>`,
|
||||
`server_mesh_<name>`, `smtp_mesh_<name>`);
|
||||
3. **adopted by the key a node's layer names** (`key`), when that account is in the list, its default
|
||||
identity has the address and it is not Owl's;
|
||||
4. **adopted by its address**, when exactly one account's default identity has it: its keys (account,
|
||||
default identity, incoming server, and the outgoing server that identity names) become the module's, and
|
||||
the account lists are left as they are;
|
||||
5. **created** under the module's own keys when no account has it: its keys are added at the end of
|
||||
`mail.accountmanager.accounts` and `mail.smtpservers` in `prefs.js`, every other key kept in its place,
|
||||
only while Betterbird is not running.
|
||||
|
||||
**Refused, for that account alone and with the remedy** (§7): two accounts with the address (remove the
|
||||
duplicate in Betterbird, or name the `key`), Owl's account at the address, a named key that does not
|
||||
qualify, an account of another kind than declared (never converted), an identity that names no outgoing
|
||||
server, and an outgoing server that another identity, neither adopted nor the module's, also uses
|
||||
(Betterbird lets identities share one, and the declared values would change that other account's sending;
|
||||
ADR 0317 §2 leaves the identity's server and the lists as they are, so the module refuses rather than give
|
||||
it a server of its own). A recorded adoption follows the identity's current outgoing server at every apply,
|
||||
under the same rule. The other accounts go ahead; the start and `betterbird_check` say it.
|
||||
`betterbird_user_js` answers an account's preferences by name with their values withheld.
|
||||
|
||||
The declared fields are written in the user.js under the account's keys, so they win at every start; only
|
||||
the fields the setting names, never the account's directory, folders, filters, caches, mail or kept
|
||||
sign-in, so an adopted account keeps its sign-in (§5). The apply names, by preference name only, each one
|
||||
whose value it changed (§4). What was adopted or created is recorded in
|
||||
`~/.config/betterbird/accounts-record.json`. **Leaving the setting** releases an adopted account (its
|
||||
lines and its record go, the account stays the person's) and takes a created account's keys out of the
|
||||
lists (its mail stays in the profile).
|
||||
|
||||
**The value is derived, not retyped** (§9): `betterbird_accounts` with `propose` builds `accounts` from the
|
||||
profile (every account but Owl's, no credential) and proposes it in the mesh-wide layer as it stands, through
|
||||
the controller's settings verb; the operator approves on a channel that proves who answers. Its answer shows
|
||||
the value it proposed (ADR 0329 §10).
|
||||
|
||||
**A node with no profile yet.** The preparation needs the profile Betterbird makes at its first start: on a
|
||||
node set up from nothing, the declared accounts are written at the second start from the launcher (or at
|
||||
`betterbird_restart`).
|
||||
|
||||
## Identities, calendars, address books, filters and tags (ADR 0329)
|
||||
|
||||
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
|
||||
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
|
||||
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
|
||||
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
|
||||
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
|
||||
|
||||
| setting | an entry | matched in the profile by |
|
||||
|---|---|---|
|
||||
| `identities` | by a name: `account` (its name in `accounts`), and for another identity than the account's default `address` and `name`; `reply-to`, `organization`, `signature` (lines), `signature-html`, `signature-above-quote`; `key` in a node's layer | the account's default (no `address`), or an identity of that account with the address |
|
||||
| `calendars` | by a name: `name`, `url` (CalDAV), `color`, `read-only`, `reminders`, `user`, `invitations` (an identities entry); `key` in a node's layer | its address |
|
||||
| `address-books` | by a name: `name`, `url` (CardDAV), `user`; `key` in a node's layer | its address |
|
||||
| `tags` | by its key: `name`, `color`, `order` | its key |
|
||||
| `filters` | by an account's name: the ordered list, each with `name`, `enabled`, `when` (new-mail, manual, after-junk, sending, archiving, periodic), `match` (all, any, every-message), `conditions` (`field`, `op`, `value`, in Betterbird's words) and `actions` (`action` in Betterbird's words; `folder` as a path within the account, or `value`) | owned whole per account |
|
||||
|
||||
**Adopted as accounts are** (§7): an entry recorded on this node is kept by its key; one under the module's own
|
||||
key (`ident_mesh_<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
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175). **They show the operator's own
|
||||
configuration with its values, and never a credential** (ADR 0329 §1, §2): every answer passes the module's
|
||||
credential check, which withholds a field whose name says password, token, secret or cookie and the password
|
||||
part of any address. No answer carries mail, a calendar entry or a contact.
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `betterbird_status` (r) | whether it runs (pid, since, scope, children); what is installed and from where, its version beside the newest published build; the policy; Owl's version against the pin and whether it is active; where its output goes; the profile and how it was found; the user.js (declared, placed, in effect, with every declared entry as it resolved); the declared accounts by count; the add-ons; the preferences that decide how its windows and notifications look |
|
||||
| `betterbird_prefs` (r) | named preferences or prefixes: value and source, and the default with the file that gives it. Withheld without its value: a name that can hold a credential, one that names an account, server, calendar or person, and every `extensions.*` but the theme's id. Values masked (the home as `~`, addresses and hosts) |
|
||||
| `betterbird_config` (r; a with `keep`) | each managed preference: its lock, the declared value, Betterbird's default where the module keeps that, the profile's saved value, the value in effect, and drift; with `keep`, this node's layer of `prefs` proposed with the profile's values |
|
||||
| `betterbird_config_apply` (r; a with `apply`) | a kept preference's drifting values listed; with `apply`, while Betterbird is quit, taken out of `prefs.js` |
|
||||
| `betterbird_user_js` (r; a with `apply`) | the user.js composed from the settings against the profile's; `apply` places it, writes what Betterbird rewrites while it is closed, and counts what waits |
|
||||
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked |
|
||||
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js and writes what waited, starts it as `mesh-betterbird`. **Refused while a composer window is open**, unless `discard_unsent` is true, which only the operator's yes justifies |
|
||||
| `betterbird_check` (r) | installed from the mesh's release; the policy and the module's defaults placed; no managed preference drifting; no ask to be the default mail client; Owl at its pin and active; one running; profile found; the user.js valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; per kind, the declared entries drifting or refused and the changes waiting for Betterbird to close; its output read |
|
||||
| `betterbird_accounts` (r; a with `propose`) | the profile's accounts with their values and the declared account each is, Owl's by name and address and marked; with `propose`, `accounts` built from them and proposed in the mesh-wide layer as it stands |
|
||||
| `betterbird_identities`, `betterbird_calendars`, `betterbird_address_books`, `betterbird_filters`, `betterbird_tags` (r) | the profile's entries with their keys and values beside the declared ones and how each resolved (adopted, created or refused with why); an ICS calendar by its name only, Owl's marked; `betterbird_filters` also where the kept filter files are |
|
||||
| `betterbird_derive` (r; a with `propose`) | the five kinds derived from the desktop nodes' profiles, per layer, with the fields another node holds differently and what was left out; with `propose`, the next layer proposed; `here` answers this node's profile, as another node asks it |
|
||||
| `betterbird_drift` (r) | per kind and declared entry: holds, differs (each field with both values), lacks, or refused; and the profile's entries nothing declares |
|
||||
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
|
||||
|
||||
### A reminder on demand
|
||||
|
||||
**It opens windows on the operator's screen**, and its description says so first, so a caller tells the
|
||||
operator before calling it. On 2026-10-12 at 12:05 a test Betterbird with an empty profile carrying the
|
||||
operator's theme looked like the operator's configuration lost; so the test is **visibly a test**: its
|
||||
profile carries a `userChrome.css` that frames every window of the test Betterbird in red and runs a red
|
||||
band reading MESH TEST across its top, and its event is called "MESH TEST reminder (a throw-away
|
||||
Betterbird of the mesh, not your calendar)". The reminder window itself is left unstyled, because how
|
||||
Betterbird draws it is what the test looks at; its title is the event's. A window title cannot be set by a
|
||||
preference, so the marking is the band, the frame and the event's name.
|
||||
|
||||
`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile
|
||||
under `/run/user/<uid>/mesh-betterbird-reminder-test/`, as the user unit
|
||||
`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's
|
||||
user.js (the setting `prefs`; never a declared account) and one calendar: a local `.ics` file with one
|
||||
event, "MESH TEST reminder", whose reminder is
|
||||
due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's
|
||||
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. The test's own
|
||||
preferences come last in its user.js, so nothing declared or carried overrides them; one of them turns
|
||||
Betterbird's update check off (`betterbird.update.checkIntervalDays` 0), because a fresh profile checks
|
||||
at its start and, when a newer build is published, opens the About window on the operator's screen
|
||||
(seen 2026-10-12). Then:
|
||||
|
||||
- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`,
|
||||
its depth (32: a visual with alpha, which the compositor blends), its map state;
|
||||
- `xorg_screenshot` with that window shows it as the screen shows it.
|
||||
|
||||
The unit removes its profile when it ends, however it ends (`ExecStopPost`): at its 10 minutes, on
|
||||
`stop`, or when the test window is closed. The test Betterbird is never counted as the operator's: its
|
||||
command line names its own profile, and `status`, `check`, `restart` and the user.js leave it out.
|
||||
|
||||
The running Betterbird, its profile and its calendars are not touched: `--no-remote` keeps the two
|
||||
apart. **`profiles.ini` and `installs.ini` are not rewritten either**, measured on the workstation
|
||||
(2026-10-09): their content and modification time (June 2023) were the same before a `--profile` start,
|
||||
while it ran, and after it ended.
|
||||
|
||||
## The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| X class / instance | `eu.betterbird.Betterbird` / `Calendar` |
|
||||
| role, type | `AlarmWindow`, `_NET_WM_WINDOW_TYPE_DIALOG` (i3 floats it) |
|
||||
| depth | 32 (ARGB visual), like every Betterbird window there |
|
||||
| size | 600×300, kept in the profile's `xulstore.json` |
|
||||
|
||||
A window of depth 32 that its program has not drawn is transparent under a compositor, not black:
|
||||
that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's
|
||||
question.
|
||||
|
||||
## What changes when it is pushed
|
||||
|
||||
The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird
|
||||
outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`):
|
||||
|
||||
| | on a node with the community package (g14, shanks) |
|
||||
|---|---|
|
||||
| the package | `betterbird-bin` removed (quit Betterbird first) |
|
||||
| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens |
|
||||
| the policy, Owl | placed; Owl installed into the profile by Betterbird at its next start where it is missing |
|
||||
| `/usr/local` | the start, the launcher entry, the tools' program |
|
||||
| `~/.config/betterbird/` | `prefs.json`, `accounts.json`, `configuration.json`, `tags.json` (each empty until its setting is approved) |
|
||||
| the profile | untouched until the next start from the launcher, `betterbird_user_js` with apply, or `betterbird_restart` |
|
||||
| the configuration | the policy's locks at Betterbird's next start; the settings in the user.js from the next start. Pushed in the order above, the operator's 21 preferences are in `prefs` before the build arrives; pushed before that layer is approved, Betterbird runs with its own defaults for them until it is |
|
||||
|
||||
## Relies on
|
||||
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module
|
||||
carries no window code of its own.
|
||||
- `systemd-run` and `journalctl` of the account's user manager.
|
||||
- The build seat's archive pinned by URL and sha256 (mesh-controller, ADR 0316 §2): a controller that does
|
||||
not know it refuses this manifest.
|
||||
- The controller's `settings` verb, for every proposal and to read a layer as it stands; its `nodes` and `node`
|
||||
verbs, for which machines hold the module; and the module's own `betterbird_derive` on those machines
|
||||
(`invokes`).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `~/.config/betterbird`: holds only what this module declares and its own tools' records: `accounts-record.json` (`cmd/betterbird-tools/adoption.go:36`), `configuration-record.json` (`owned.go:33`) and the `kept/` folder of replaced filter files (`filters.go:261`); anything else is moved out by the node-engine to `~/.local/state/mesh-moved/betterbird/` with a list, never deleted (ADR 0422 §4).
|
||||
- `/opt/betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/opt/betterbird/owl`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/opt/betterbird/distribution`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/opt/betterbird/defaults/pref`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/usr/local/lib/mesh-betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
@@ -1,842 +0,0 @@
|
||||
package main
|
||||
|
||||
// The operator's mail accounts, declared without credentials (novox/hq ADR 0316 §10–§14, to-be 56 §4).
|
||||
//
|
||||
// The setting `accounts` reaches the machine as ~/.config/betterbird/accounts.json, a file the node-engine
|
||||
// writes from the module's settings layers. Per account it names the address, the incoming server and the
|
||||
// outgoing server; never a password or a token, and the whole file is refused when any key in it is shaped
|
||||
// like one. Betterbird's accounts are preferences, so a declared account becomes:
|
||||
//
|
||||
// - its declared fields as preferences in the user.js the module places, where they win at every start
|
||||
// (§11): under the keys of the account the profile already holds at that address, which the module adopts
|
||||
// (novox/hq ADR 0317), or, where the profile holds none, under keys of the module's own that end in
|
||||
// mesh_<name> (account_mesh_<name>, id_mesh_<name>, server_mesh_<name>, smtp_mesh_<name>);
|
||||
// - for an account the module created, its key in the account list (mail.accountmanager.accounts) and in the
|
||||
// list of outgoing servers (mail.smtpservers) of the profile's prefs.js: written into, never owned, and
|
||||
// only while Betterbird is not running, because it rewrites prefs.js when it quits (§12). Every other key
|
||||
// keeps its place, and an adopted account's keys are in the lists already.
|
||||
//
|
||||
// Which account is adopted, created or refused is adoption.go's.
|
||||
//
|
||||
// Nobody's password passes through here: an account signs in at its first use, in Betterbird's own window,
|
||||
// and Betterbird keeps the result in its login store (§14). Of that store the module reads only the server
|
||||
// names each entry is kept for, which Betterbird writes in the clear, to count the declared accounts that
|
||||
// have not signed in yet (§16); never a user name or a password, which are encrypted there anyway.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// accountsFile is the setting accounts on the machine, written by the node-engine.
|
||||
const accountsFile = ".config/betterbird/accounts.json"
|
||||
|
||||
// Account is one declared mail account, in the shape of the setting accounts: keyed by its name.
|
||||
type Account struct {
|
||||
Address string `json:"address"`
|
||||
// FromName is the name the account's mail is sent under. Absent: Betterbird's, none.
|
||||
FromName string `json:"from-name,omitempty"`
|
||||
// Label is what Betterbird shows the account as. Absent: its address.
|
||||
Label string `json:"label,omitempty"`
|
||||
Incoming Server `json:"incoming"`
|
||||
Outgoing Server `json:"outgoing"`
|
||||
// Key is the profile's account key to adopt, given only in a node's own layer to settle an address the
|
||||
// profile holds more than once (novox/hq ADR 0317 §8).
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// Server is an account's incoming or outgoing server: never a password or a token.
|
||||
type Server struct {
|
||||
// Kind is imap or pop3, for the incoming server; the outgoing one is smtp and says nothing.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Security string `json:"security"`
|
||||
SignIn string `json:"sign-in"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// The words a server's connection security and sign-in method are said in, and Betterbird's numbers for
|
||||
// them (socketType / try_ssl, authMethod).
|
||||
var (
|
||||
securities = map[string]int{"none": 0, "starttls": 2, "tls": 3}
|
||||
signIns = map[string]int{"none": 1, "password": 3, "encrypted-password": 4, "kerberos": 5, "ntlm": 6,
|
||||
"tls-certificate": 7, "oauth": 10}
|
||||
)
|
||||
|
||||
func wordFor(words map[string]int, n int) string {
|
||||
for w, v := range words {
|
||||
if v == n {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
accountName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
|
||||
addressShape = regexp.MustCompile(`^[^\s@"\\]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$`)
|
||||
hostShape = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?$`)
|
||||
profileKey = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`)
|
||||
// plainText is a value written into a preference: no quote, backslash or line break.
|
||||
plainText = regexp.MustCompile(`^[^"\\\n\r]*$`)
|
||||
// secretKey is a key the accounts file may never carry, at any depth: the setting holds no credential.
|
||||
secretKey = regexp.MustCompile(`(?i)(pass|secret|token|oauth.?refresh|refresh|credential|api.?key|private.?key|cookie|bearer)`)
|
||||
)
|
||||
|
||||
// keysOf are the module's own keys for a declared account: each ends in mesh_<name>.
|
||||
func keysOf(name string) (account, identity, server, smtp string) {
|
||||
return "account_mesh_" + name, "id_mesh_" + name, "server_mesh_" + name, "smtp_mesh_" + name
|
||||
}
|
||||
|
||||
// ownKey is a key of an account list the module wrote, and the name it was declared under.
|
||||
var ownKey = regexp.MustCompile(`^(?:account|smtp)_mesh_([a-z][a-z0-9]{0,30})$`)
|
||||
|
||||
// readAccounts reads the declared accounts. A missing file is no account (the module is older here); a file
|
||||
// that carries a key shaped like a credential, an unknown field or a value that cannot be written is refused
|
||||
// whole, naming the key and never a value.
|
||||
func (m *Machine) readAccounts() (map[string]Account, bool, error) {
|
||||
raw, err := readBounded(m.home(accountsFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]Account{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
accounts, err := parseAccounts(raw)
|
||||
return accounts, true, err
|
||||
}
|
||||
|
||||
func parseAccounts(raw []byte) (map[string]Account, error) {
|
||||
var tree any
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return nil, fmt.Errorf("~/%s is not JSON: %v", accountsFile, err)
|
||||
}
|
||||
if where := secretKeyIn(tree, ""); where != "" {
|
||||
// An account's name is said by nothing: its segment of the path is withheld.
|
||||
if parts := strings.SplitN(where, ".", 3); len(parts) == 3 && parts[0] == "accounts" {
|
||||
where = "accounts.<an account>." + parts[2]
|
||||
}
|
||||
return nil, fmt.Errorf("the setting accounts carries %s, a key shaped like a credential: an account is declared "+
|
||||
"without its password or token, which it signs in with at its first use (novox/hq ADR 0316 §10, §14). "+
|
||||
"Take it out at the controller's terminal", where)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil, fmt.Errorf("~/%s is not an object: %v", accountsFile, err)
|
||||
}
|
||||
accounts := map[string]Account{}
|
||||
if body, ok := top["accounts"]; ok && string(body) != "null" {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&accounts); err != nil {
|
||||
return nil, fmt.Errorf("the setting accounts is not a map of accounts by name: %v", err)
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
names := sortedNames(accounts)
|
||||
for i, name := range names {
|
||||
problems = append(problems, accountProblems(fmt.Sprintf("declared account %d of %d", i+1, len(names)),
|
||||
name, accounts[name])...)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the setting accounts is refused: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// secretKeyIn is the path of the first key shaped like a credential in a JSON tree, or "".
|
||||
func secretKeyIn(node any, at string) string {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
here := k
|
||||
if at != "" {
|
||||
here = at + "." + k
|
||||
}
|
||||
if secretKey.MatchString(k) {
|
||||
return here
|
||||
}
|
||||
if w := secretKeyIn(v[k], here); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, x := range v {
|
||||
if w := secretKeyIn(x, fmt.Sprintf("%s[%d]", at, i)); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedNames(accounts map[string]Account) []string {
|
||||
names := make([]string, 0, len(accounts))
|
||||
for n := range accounts {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// accountProblems is what is wrong with one declared account, said of it by its place (label) and never by a
|
||||
// value of it, its name included (novox/hq ADR 0316 §16).
|
||||
func accountProblems(label, name string, a Account) []string {
|
||||
var out []string
|
||||
say := func(format string, args ...any) { out = append(out, label+": "+fmt.Sprintf(format, args...)) }
|
||||
if !accountName.MatchString(name) {
|
||||
say("an account's name is lower-case letters and digits, starting with a letter (it names the module's keys, mesh_<name>)")
|
||||
}
|
||||
if !addressShape.MatchString(a.Address) {
|
||||
say("the address is not a mail address")
|
||||
}
|
||||
if a.Key != "" && !profileKey.MatchString(a.Key) {
|
||||
say("key is an account key of the profile, such as account2")
|
||||
}
|
||||
for field, v := range map[string]string{"from-name": a.FromName, "label": a.Label} {
|
||||
if !plainText.MatchString(v) {
|
||||
say("%s holds a quote, a backslash or a line break", field)
|
||||
}
|
||||
}
|
||||
for side, s := range map[string]Server{"incoming": a.Incoming, "outgoing": a.Outgoing} {
|
||||
switch {
|
||||
case side == "incoming" && s.Kind != "imap" && s.Kind != "pop3":
|
||||
say("the incoming server's kind is imap or pop3")
|
||||
case side == "outgoing" && s.Kind != "" && s.Kind != "smtp":
|
||||
say("the outgoing server is smtp")
|
||||
}
|
||||
if !hostShape.MatchString(s.Host) {
|
||||
say("the %s server's host is not a host name", side)
|
||||
}
|
||||
if s.Port < 0 || s.Port > 65535 {
|
||||
say("the %s server's port is not a port", side)
|
||||
}
|
||||
if _, ok := securities[s.Security]; !ok {
|
||||
say("the %s server's security is tls, starttls or none", side)
|
||||
}
|
||||
if _, ok := signIns[s.SignIn]; !ok || (side == "incoming" && s.SignIn == "none") {
|
||||
say("the %s server's sign-in is password, encrypted-password, oauth, kerberos, ntlm or tls-certificate%s",
|
||||
side, map[bool]string{true: ", or none", false: ""}[side == "outgoing"])
|
||||
}
|
||||
if !plainText.MatchString(s.User) {
|
||||
say("the %s server's user holds a quote, a backslash or a line break", side)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quoted is a preference's string value as user.js writes it.
|
||||
func quoted(s string) string { return strconv.Quote(s) }
|
||||
|
||||
// declaredPrefs are a declared account's fields as preferences under the keys it resolved to, name to value
|
||||
// as user.js writes it, in a fixed order. Only the fields the setting names (novox/hq ADR 0317 §5): never
|
||||
// the account's directory, folders, filters or caches. An adopted account keeps its kind, the outgoing server
|
||||
// its identity names, and its own name and sender's name where the declaration says none; a created one
|
||||
// gets every field.
|
||||
func declaredPrefs(k keySet, a Account, adopted bool) [][2]string {
|
||||
label := a.Label
|
||||
if label == "" && !adopted {
|
||||
label = a.Address
|
||||
}
|
||||
inPort := a.Incoming.Port
|
||||
if inPort == 0 {
|
||||
inPort = defaultPort(a.Incoming.Kind, a.Incoming.Security)
|
||||
}
|
||||
outPort := a.Outgoing.Port
|
||||
if outPort == 0 {
|
||||
outPort = defaultPort("smtp", a.Outgoing.Security)
|
||||
}
|
||||
var out [][2]string
|
||||
add := func(key, value string) { out = append(out, [2]string{key, value}) }
|
||||
if !adopted {
|
||||
add("mail.account."+k.Account+".identities", quoted(k.Identity))
|
||||
add("mail.account."+k.Account+".server", quoted(k.Server))
|
||||
}
|
||||
if a.FromName != "" || !adopted {
|
||||
add("mail.identity."+k.Identity+".fullName", quoted(a.FromName))
|
||||
}
|
||||
add("mail.identity."+k.Identity+".useremail", quoted(a.Address))
|
||||
if !adopted {
|
||||
add("mail.identity."+k.Identity+".smtpServer", quoted(k.SMTP))
|
||||
add("mail.identity."+k.Identity+".valid", "true")
|
||||
add("mail.server."+k.Server+".type", quoted(a.Incoming.Kind))
|
||||
}
|
||||
add("mail.server."+k.Server+".hostname", quoted(a.Incoming.Host))
|
||||
add("mail.server."+k.Server+".port", strconv.Itoa(inPort))
|
||||
add("mail.server."+k.Server+".socketType", strconv.Itoa(securities[a.Incoming.Security]))
|
||||
add("mail.server."+k.Server+".authMethod", strconv.Itoa(signIns[a.Incoming.SignIn]))
|
||||
add("mail.server."+k.Server+".userName", quoted(a.Incoming.User))
|
||||
if label != "" {
|
||||
add("mail.server."+k.Server+".name", quoted(label))
|
||||
}
|
||||
add("mail.smtpserver."+k.SMTP+".hostname", quoted(a.Outgoing.Host))
|
||||
add("mail.smtpserver."+k.SMTP+".port", strconv.Itoa(outPort))
|
||||
add("mail.smtpserver."+k.SMTP+".try_ssl", strconv.Itoa(securities[a.Outgoing.Security]))
|
||||
add("mail.smtpserver."+k.SMTP+".authMethod", strconv.Itoa(signIns[a.Outgoing.SignIn]))
|
||||
add("mail.smtpserver."+k.SMTP+".username", quoted(a.Outgoing.User))
|
||||
if label != "" {
|
||||
add("mail.smtpserver."+k.SMTP+".description", quoted(label))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultPort is the port a server listens on when the declaration says none.
|
||||
func defaultPort(kind, security string) int {
|
||||
switch {
|
||||
case kind == "imap" && security == "tls":
|
||||
return 993
|
||||
case kind == "imap":
|
||||
return 143
|
||||
case kind == "pop3" && security == "tls":
|
||||
return 995
|
||||
case kind == "pop3":
|
||||
return 110
|
||||
case security == "tls":
|
||||
return 465
|
||||
case security == "starttls":
|
||||
return 587
|
||||
}
|
||||
return 25
|
||||
}
|
||||
|
||||
// composeUserJS is the user.js the module places in the profile: the declared file (the setting prefs), then
|
||||
// each adopted or created account's declared fields under its keys. A refused account has none.
|
||||
func composeUserJS(declaredText string, accounts map[string]Account, resolved []resolvedAccount) string {
|
||||
var lines []string
|
||||
for _, r := range resolved {
|
||||
if r.How == refused {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("// declared account %d: %s under %s", r.Place, r.How, r.Keys.Account))
|
||||
for _, p := range declaredPrefs(r.Keys, accounts[r.name], r.How == adopted) {
|
||||
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(p[0]), p[1]))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return declaredText
|
||||
}
|
||||
return strings.TrimRight(declaredText, "\n") + "\n\n" +
|
||||
"// The accounts the setting accounts declares (novox/hq ADR 0316 §11, ADR 0317): the fields it names, under\n" +
|
||||
"// the keys of the account adopted or created. No password or token: an account signs in at its first use.\n" +
|
||||
strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
// profileAccount is one account the profile holds, as its preferences say it.
|
||||
type profileAccount struct {
|
||||
Key, Server, Kind string
|
||||
// Identities are its identities, the default first, and Addresses their addresses in that order.
|
||||
Identities []string
|
||||
Addresses []string
|
||||
Owl, Ours bool
|
||||
}
|
||||
|
||||
// profileAccounts reads the accounts the profile's list names, from prefs.js and user.js (user.js wins).
|
||||
func profileAccounts(prefs map[string]string) []profileAccount {
|
||||
var out []profileAccount
|
||||
for _, key := range listOf(prefs["mail.accountmanager.accounts"]) {
|
||||
a := profileAccount{Key: key, Server: unquote(prefs["mail.account."+key+".server"])}
|
||||
a.Kind = unquote(prefs["mail.server."+a.Server+".type"])
|
||||
a.Owl = strings.HasPrefix(a.Kind, "owl")
|
||||
a.Ours = ownKey.MatchString(key)
|
||||
for _, id := range listOf(prefs["mail.account."+key+".identities"]) {
|
||||
a.Identities = append(a.Identities, id)
|
||||
a.Addresses = append(a.Addresses, unquote(prefs["mail.identity."+id+".useremail"]))
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergedPrefs are a profile's preferences as Betterbird starts with them: prefs.js, then user.js over it.
|
||||
func mergedPrefs(profile string) map[string]string {
|
||||
prefsJS, _ := readBounded(filepath.Join(profile, "prefs.js"))
|
||||
userJS, _ := readBounded(filepath.Join(profile, "user.js"))
|
||||
out := readPrefs(prefsJS)
|
||||
for k, v := range readPrefs(userJS) {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listOf reads a comma-separated list preference's value.
|
||||
func listOf(v string) []string {
|
||||
var out []string
|
||||
for _, k := range strings.Split(unquote(v), ",") {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unquote is a preference's string value without its quotes; any other value as written.
|
||||
func unquote(v string) string {
|
||||
if s, err := strconv.Unquote(v); err == nil {
|
||||
return s
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// writeAccountLists writes the module's keys into the profile's account list and list of outgoing servers
|
||||
// (§12): the declared accounts' keys added at the end, the module's keys of accounts no longer declared taken
|
||||
// out, every other key and line kept as it was. Only while Betterbird is not running. Says what changed.
|
||||
func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount) (string, error) {
|
||||
path := filepath.Join(profile, "prefs.js")
|
||||
raw, err := readBounded(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
var wantAccounts, wantSMTP []string
|
||||
for _, r := range resolved {
|
||||
if r.How == created {
|
||||
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
|
||||
}
|
||||
}
|
||||
out, changed := withListed(string(raw), map[string][]string{
|
||||
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP})
|
||||
if !changed {
|
||||
return "unchanged", nil
|
||||
}
|
||||
// Only while Betterbird is closed: no main process and no lock on the profile (novox/hq ADR 0316 §12, ADR 0329 §9).
|
||||
if why := m.closedFor(profile); why != "" {
|
||||
return "", errors.New(why + ": the account list is written at its next start from the launcher, or by betterbird_restart")
|
||||
}
|
||||
mode := os.FileMode(0o600)
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
if err := writeAtomic(path, []byte(out), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil
|
||||
}
|
||||
|
||||
// withListed is prefs.js with each list preference holding the module's wanted keys: its own keys that are
|
||||
// not wanted taken out, the wanted ones not there added at its end, everything else as it was. A list the
|
||||
// file does not hold yet is added as a line, when anything is wanted in it.
|
||||
func withListed(text string, want map[string][]string) (string, bool) {
|
||||
changed := false
|
||||
done := map[string]bool{}
|
||||
var lines []string
|
||||
s := bufio.NewScanner(strings.NewReader(text))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
if mm := prefLine.FindStringSubmatch(line); mm != nil {
|
||||
if keys, ok := want[mm[1]]; ok && !done[mm[1]] {
|
||||
done[mm[1]] = true
|
||||
next := listedWith(listOf(mm[2]), keys)
|
||||
if next != strings.Join(listOf(mm[2]), ",") {
|
||||
line = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), quoted(next))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
names := make([]string, 0, len(want))
|
||||
for n := range want {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if !done[n] && len(want[n]) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(n), quoted(strings.Join(want[n], ","))))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if text == "" || strings.HasSuffix(text, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// listedWith is a list with the module's own keys made the wanted ones, every other key in its place.
|
||||
func listedWith(have, wanted []string) string {
|
||||
want := map[string]bool{}
|
||||
for _, k := range wanted {
|
||||
want[k] = true
|
||||
}
|
||||
var out []string
|
||||
there := map[string]bool{}
|
||||
for _, k := range have {
|
||||
if ownKey.MatchString(k) && !want[k] {
|
||||
continue
|
||||
}
|
||||
out = append(out, k)
|
||||
there[k] = true
|
||||
}
|
||||
for _, k := range wanted {
|
||||
if !there[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// AccountCount is what status and check say of the accounts: numbers, places and keys, never an address, a
|
||||
// host or a name (novox/hq ADR 0316 §16, ADR 0317 §3).
|
||||
type AccountCount struct {
|
||||
Declared int `json:"declared"`
|
||||
// InList are the declared accounts the profile's account list names, adopted or created.
|
||||
InList int `json:"declared_in_the_list"`
|
||||
// Others are the accounts the profile holds that no declaration names (the work account in Owl among them).
|
||||
Others int `json:"other_accounts"`
|
||||
// WithoutSignIn are the declared accounts with no sign-in kept in Betterbird's login store yet.
|
||||
WithoutSignIn int `json:"declared_without_a_sign_in,omitempty"`
|
||||
// Each is every declared account by its place in the setting: adopted, created or refused, and its keys.
|
||||
Each []resolvedAccount `json:"each,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
// countAccounts counts the declared accounts against the profile's.
|
||||
func (m *Machine) countAccounts(profile, profileDir string) AccountCount {
|
||||
var c AccountCount
|
||||
accounts, _, err := m.readAccounts()
|
||||
if err != nil {
|
||||
c.Problem = err.Error()
|
||||
return c
|
||||
}
|
||||
c.Declared = len(accounts)
|
||||
prefs := mergedPrefs(profile)
|
||||
have := profileAccounts(prefs)
|
||||
c.Each = resolveAccounts(accounts, prefs, m.readRecord(profileDir))
|
||||
mine := map[string]bool{}
|
||||
refusedN := 0
|
||||
for _, r := range c.Each {
|
||||
if r.How == refused {
|
||||
refusedN++
|
||||
continue
|
||||
}
|
||||
mine[r.Keys.Account] = true
|
||||
}
|
||||
for _, a := range have {
|
||||
switch {
|
||||
case mine[a.Key]:
|
||||
c.InList++
|
||||
case a.Kind != "none":
|
||||
c.Others++
|
||||
}
|
||||
}
|
||||
if refusedN > 0 {
|
||||
c.Problem = fmt.Sprintf("%d declared account(s) are refused: each says why and what to do", refusedN)
|
||||
}
|
||||
c.WithoutSignIn = len(withoutSignIn(accounts, keptSignIns(profile)))
|
||||
return c
|
||||
}
|
||||
|
||||
// keptSignIns are the server names Betterbird's login store keeps a sign-in for: the `hostname` of each
|
||||
// entry of logins.json, which Betterbird writes in the clear. Nothing else of the file is read.
|
||||
func keptSignIns(profile string) map[string]bool {
|
||||
raw, err := readBounded(filepath.Join(profile, "logins.json"))
|
||||
out := map[string]bool{}
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var store struct {
|
||||
Logins []struct {
|
||||
Hostname string `json:"hostname"`
|
||||
} `json:"logins"`
|
||||
}
|
||||
if json.Unmarshal(raw, &store) != nil {
|
||||
return out
|
||||
}
|
||||
for _, l := range store.Logins {
|
||||
out[strings.ToLower(l.Hostname)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oauthIssuers are the sign-in services of the providers whose servers sign in through OAuth, by a suffix of
|
||||
// the server's host: their kept sign-in is under the service's name, not the server's.
|
||||
var oauthIssuers = map[string]string{"gmail.com": "oauth://accounts.google.com",
|
||||
"googlemail.com": "oauth://accounts.google.com", "office365.com": "oauth://login.microsoftonline.com",
|
||||
"outlook.com": "oauth://login.microsoftonline.com"}
|
||||
|
||||
// withoutSignIn are the declared accounts whose incoming server has no sign-in kept yet. A server signed in
|
||||
// to without a password (none, kerberos, ntlm, a certificate) keeps nothing and is never counted.
|
||||
func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string {
|
||||
var out []string
|
||||
for _, name := range sortedNames(accounts) {
|
||||
in := accounts[name].Incoming
|
||||
switch in.SignIn {
|
||||
case "password", "encrypted-password":
|
||||
if !kept[strings.ToLower(in.Kind+"://"+in.Host)] {
|
||||
out = append(out, name)
|
||||
}
|
||||
case "oauth":
|
||||
issuer := ""
|
||||
for suffix, i := range oauthIssuers {
|
||||
if strings.HasSuffix(strings.ToLower(in.Host), suffix) {
|
||||
issuer = i
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for k := range kept {
|
||||
if (issuer != "" && k == issuer) || (issuer == "" && strings.HasPrefix(k, "oauth://")) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountsAnswer is what betterbird_accounts answers its caller: the profile's accounts with the operator's own
|
||||
// values (novox/hq ADR 0329 §1, §12), Owl's marked and by name and address only (§13), and counts and kinds.
|
||||
// Never a password, a token or anything of the login store (§2).
|
||||
type AccountsAnswer struct {
|
||||
Profile string `json:"profile"`
|
||||
// Accounts are the profile's accounts, in its list's order.
|
||||
Accounts []listedAccount `json:"accounts"`
|
||||
// Derived is the value of the setting accounts the propose built, as it was proposed (ADR 0329 §10).
|
||||
Derived map[string]Account `json:"derived,omitempty"`
|
||||
// Declarable are the profile's mail accounts a declaration can hold (IMAP and POP3), by kind.
|
||||
Declarable map[string]int `json:"declarable"`
|
||||
// KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13).
|
||||
KeptByOwl int `json:"kept_by_owl"`
|
||||
// NotDeclarable are accounts of another kind (local folders, feeds, news, chat), by kind.
|
||||
NotDeclarable map[string]int `json:"not_declarable,omitempty"`
|
||||
// AlreadyDeclared are the declarable accounts under the module's own keys.
|
||||
AlreadyDeclared int `json:"under_the_modules_keys"`
|
||||
// Proposed is the controller's answer to the proposal, with every address and host masked.
|
||||
Proposed string `json:"proposed,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// listedAccount is one account of the profile as betterbird_accounts lists it.
|
||||
type listedAccount struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
// DeclaredAs is the name the setting accounts declares it under, or empty: the person's own.
|
||||
DeclaredAs string `json:"declared_as,omitempty"`
|
||||
Owl bool `json:"owl,omitempty"`
|
||||
// Name is what Betterbird shows it as, and Address its default identity's.
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Value *Account `json:"value,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// profileDeclarable reads the profile's accounts: the value a declaration of them would be, by name, and
|
||||
// what is answered of them.
|
||||
func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) {
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
return nil, AccountsAnswer{}, err
|
||||
}
|
||||
prefs := mergedPrefs(p.path)
|
||||
a := AccountsAnswer{Profile: p.Dir, Accounts: []listedAccount{}, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
|
||||
value := map[string]Account{}
|
||||
used := map[string]bool{}
|
||||
declaredAs := map[string]string{}
|
||||
if accounts, _, err := m.readAccounts(); err == nil {
|
||||
for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) {
|
||||
if r.How != refused {
|
||||
declaredAs[r.Keys.Account] = r.name
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pa := range profileAccounts(prefs) {
|
||||
listed := listedAccount{Key: pa.Key, Kind: pa.Kind, DeclaredAs: declaredAs[pa.Key], Owl: pa.Owl,
|
||||
Name: unquote(prefs["mail.server."+pa.Server+".name"])}
|
||||
if len(pa.Addresses) > 0 {
|
||||
listed.Address = pa.Addresses[0]
|
||||
}
|
||||
switch {
|
||||
case pa.Owl:
|
||||
a.KeptByOwl++
|
||||
listed.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
|
||||
a.Accounts = append(a.Accounts, listed)
|
||||
continue
|
||||
case pa.Kind != "imap" && pa.Kind != "pop3":
|
||||
kind := pa.Kind
|
||||
if kind == "none" {
|
||||
kind = "local folders"
|
||||
}
|
||||
a.NotDeclarable[kind]++
|
||||
listed.Note = "not an account a declaration holds"
|
||||
a.Accounts = append(a.Accounts, listed)
|
||||
continue
|
||||
}
|
||||
a.Declarable[pa.Kind]++
|
||||
if pa.Ours {
|
||||
a.AlreadyDeclared++
|
||||
}
|
||||
address := ""
|
||||
if len(pa.Addresses) > 0 {
|
||||
address = pa.Addresses[0]
|
||||
}
|
||||
name := nameFor(pa, address, used)
|
||||
if declaredAs[pa.Key] != "" {
|
||||
name = declaredAs[pa.Key]
|
||||
}
|
||||
used[name] = true
|
||||
acc := readAccount(prefs, pa)
|
||||
value[name] = acc
|
||||
listed.Value = &acc
|
||||
a.Accounts = append(a.Accounts, listed)
|
||||
}
|
||||
if len(a.NotDeclarable) == 0 {
|
||||
a.NotDeclarable = nil
|
||||
}
|
||||
return value, a, nil
|
||||
}
|
||||
|
||||
// asker calls a verb of the mesh as this module, through the runtime: a variable so a test answers.
|
||||
var asker = stdio.Ask
|
||||
|
||||
// ProfileAccounts says, by count and kind, which accounts the profile in use holds; with propose it builds
|
||||
// the setting accounts from them (every account but Owl's, without any credential) and proposes it through
|
||||
// the controller's settings verb for the operator's Approve (novox/hq ADR 0317 §9, ADR 0277). The value
|
||||
// never comes back to the caller: the operator reads it whole at their desk (`settings proposals <id>`) and
|
||||
// checks the fingerprint the ask shows.
|
||||
func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
|
||||
value, a, err := m.profileDeclarable()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Note = "the profile's accounts with their values, never a password or token (novox/hq ADR 0329 §1, §2). With " +
|
||||
"propose, the setting accounts is built from them (never Owl's) and proposed to the operator in the mesh-wide " +
|
||||
"layer as it stands, who approves it on a channel that proves who answers (novox/hq ADR 0317 §9)"
|
||||
if !propose {
|
||||
return a, nil
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return a, errors.New("the profile holds no IMAP or POP3 account to declare: nothing was proposed")
|
||||
}
|
||||
var problems []string
|
||||
for i, name := range sortedNames(value) {
|
||||
if p := accountProblems("", name, value[name]); len(p) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("account %d of %d: %d field(s) cannot be declared as the profile "+
|
||||
"holds them", i+1, len(value), len(p)))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
|
||||
strings.Join(problems, "; "))
|
||||
}
|
||||
// A layer is replaced whole: the proposal carries the mesh-wide layer as it stands, with this value of accounts.
|
||||
layer, err := readLayer("")
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("nothing was proposed: %v", err)
|
||||
}
|
||||
layer["accounts"] = value
|
||||
a.Derived = value
|
||||
said, err := proposeLayer("", layer, false)
|
||||
a.Proposed = m.maskValue(said)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
|
||||
// or the protocol's content list.
|
||||
func verbSaid(raw json.RawMessage) (string, bool) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return verbSaid(json.RawMessage(s))
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return string(raw), true
|
||||
}
|
||||
if content, has := m["content"]; has {
|
||||
var items []struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
var e struct {
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
|
||||
out, ok := verbSaid(json.RawMessage(items[0].Text))
|
||||
return out, ok && !e.IsError
|
||||
}
|
||||
}
|
||||
if okRaw, has := m["ok"]; has {
|
||||
var ok bool
|
||||
_ = json.Unmarshal(okRaw, &ok)
|
||||
var output string
|
||||
_ = json.Unmarshal(m["output"], &output)
|
||||
return output, ok
|
||||
}
|
||||
return string(raw), true
|
||||
}
|
||||
|
||||
// readAccount reads one profile account's preferences into the shape of the setting.
|
||||
func readAccount(prefs map[string]string, pa profileAccount) Account {
|
||||
num := func(key string, def int) int {
|
||||
if n, err := strconv.Atoi(prefs[key]); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
text := func(key string) string { return unquote(prefs[key]) }
|
||||
srv := "mail.server." + pa.Server + "."
|
||||
acc := Account{Incoming: Server{Kind: pa.Kind, Host: text(srv + "hostname"), User: text(srv + "userName"),
|
||||
Security: wordFor(securities, num(srv+"socketType", 0)), SignIn: wordFor(signIns, num(srv+"authMethod", 3))}}
|
||||
acc.Incoming.Port = num(srv+"port", defaultPort(pa.Kind, acc.Incoming.Security))
|
||||
ids := listOf(prefs["mail.account."+pa.Key+".identities"])
|
||||
if len(ids) > 0 {
|
||||
id := "mail.identity." + ids[0] + "."
|
||||
acc.Address, acc.FromName = text(id+"useremail"), text(id+"fullName")
|
||||
smtp := text(id + "smtpServer")
|
||||
if smtp == "" {
|
||||
smtp = text("mail.smtp.defaultserver")
|
||||
}
|
||||
if smtp != "" {
|
||||
s := "mail.smtpserver." + smtp + "."
|
||||
acc.Outgoing = Server{Host: text(s + "hostname"), User: text(s + "username"),
|
||||
Security: wordFor(securities, num(s+"try_ssl", 0)), SignIn: wordFor(signIns, num(s+"authMethod", 3))}
|
||||
acc.Outgoing.Port = num(s+"port", defaultPort("smtp", acc.Outgoing.Security))
|
||||
}
|
||||
}
|
||||
if label := text(srv + "name"); label != "" && label != acc.Address {
|
||||
acc.Label = label
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// nameFor is the name a profile account is answered under: its own name when the module declared it, else
|
||||
// the first label of its address's domain, made unique.
|
||||
func nameFor(pa profileAccount, address string, used map[string]bool) string {
|
||||
if mm := ownKey.FindStringSubmatch(pa.Key); mm != nil {
|
||||
return mm[1]
|
||||
}
|
||||
base := "account"
|
||||
if _, domain, ok := strings.Cut(address, "@"); ok {
|
||||
label, _, _ := strings.Cut(strings.ToLower(domain), ".")
|
||||
label = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(label, "")
|
||||
if label != "" && label[0] >= 'a' && label[0] <= 'z' {
|
||||
base = label
|
||||
}
|
||||
}
|
||||
if len(base) > 28 {
|
||||
base = base[:28]
|
||||
}
|
||||
name := base
|
||||
for i := 2; used[name]; i++ {
|
||||
name = base + strconv.Itoa(i)
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The setting accounts (novox/hq ADR 0316 §10–§16, ADR 0317 §9 for betterbird_accounts): declared without
|
||||
// credentials, written under the module's own keys, the account list written into and never owned, refused
|
||||
// rather than duplicated, and said by count.
|
||||
|
||||
const personal = `{"accounts": {
|
||||
"personal": {"address": "me@mail.example.org", "from-name": "Me",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password", "user": "me"},
|
||||
"outgoing": {"host": "smtp.example.org", "port": 587, "security": "starttls", "sign-in": "password", "user": "me"}}
|
||||
}}`
|
||||
|
||||
// profileWithAccounts is a profile holding an account of the person's at another address, Owl's work account,
|
||||
// local folders, and an account list in that order.
|
||||
func (f *fake) profileWithAccounts() {
|
||||
f.betterbird()
|
||||
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
|
||||
`user_pref("mail.account.account1.server", "server1");`+"\n"+
|
||||
`user_pref("mail.account.account1.identities", "id1");`+"\n"+
|
||||
`user_pref("mail.account.account2.server", "server2");`+"\n"+
|
||||
`user_pref("mail.account.account3.server", "server3");`+"\n"+
|
||||
`user_pref("mail.account.account3.identities", "id3");`+"\n"+
|
||||
`user_pref("mail.accountmanager.accounts", "account1,account3,account2");`+"\n"+
|
||||
`user_pref("mail.identity.id1.useremail", "other@mail.example.org");`+"\n"+
|
||||
`user_pref("mail.identity.id1.fullName", "Other");`+"\n"+
|
||||
`user_pref("mail.identity.id1.smtpServer", "smtp1");`+"\n"+
|
||||
`user_pref("mail.identity.id3.useremail", "work@corp.example.com");`+"\n"+
|
||||
`user_pref("mail.server.server1.type", "imap");`+"\n"+
|
||||
`user_pref("mail.server.server1.hostname", "imap.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server1.socketType", 3);`+"\n"+
|
||||
`user_pref("mail.server.server1.authMethod", 10);`+"\n"+
|
||||
`user_pref("mail.server.server1.userName", "other");`+"\n"+
|
||||
`user_pref("mail.server.server1.directory", "/home/operator/.thunderbird/x/ImapMail/imap.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server2.type", "none");`+"\n"+
|
||||
`user_pref("mail.server.server3.type", "owl-ews");`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.hostname", "smtp.example.org");`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.try_ssl", 3);`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.authMethod", 10);`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.username", "other");`+"\n"+
|
||||
`user_pref("mail.smtpservers", "smtp1");`+"\n"+
|
||||
`user_pref("mailnews.tags.version", 2);`+"\n")
|
||||
}
|
||||
|
||||
func (f *fake) declareAccounts(body string) {
|
||||
f.write(testHome+"/"+accountsFile, body)
|
||||
}
|
||||
|
||||
func (f *fake) read(path string) string {
|
||||
f.t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(f.Root, path))
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAnAccountsValueCarryingAPasswordLikeFieldIsRefusedWithoutItsValue(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"accounts": {"personal": {"address": "me@mail.example.org", "password": "hunter2"}}}`,
|
||||
`{"accounts": {"personal": {"incoming": {"oauth_refresh": "1//hunter2"}}}}`,
|
||||
`{"token": "hunter2", "accounts": {}}`,
|
||||
} {
|
||||
_, err := parseAccounts([]byte(body))
|
||||
if err == nil || !strings.Contains(err.Error(), "shaped like a credential") || strings.Contains(err.Error(), "hunter2") {
|
||||
t.Errorf("%s: %v", body, err)
|
||||
}
|
||||
}
|
||||
// An unknown field is refused too, and a value that cannot be declared, said by the account's place.
|
||||
if _, err := parseAccounts([]byte(`{"accounts": {"personal": {"address": "me@mail.example.org", "keychain": 1}}}`)); err == nil {
|
||||
t.Error("an unknown field was taken")
|
||||
}
|
||||
_, err := parseAccounts([]byte(strings.Replace(personal, `"security": "tls"`, `"security": "maybe"`, 1)))
|
||||
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 1") || strings.Contains(err.Error(), "personal") ||
|
||||
strings.Contains(err.Error(), "example.org") {
|
||||
t.Errorf("a bad value is said by the account's place, never its name or a value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSettingPrefsRefusesAnAccountsPreferences(t *testing.T) {
|
||||
for _, name := range []string{"mail.account.account1.server", "mail.identity.id1.useremail", "mail.server.server1.hostname",
|
||||
"mail.smtpserver.smtp1.hostname", "mail.accountmanager.accounts", "mail.smtpservers"} {
|
||||
err := validUserJS(mark + ")\nuser_pref(\"" + name + "\", \"x\");\n")
|
||||
if err == nil || !strings.Contains(err.Error(), "setting accounts") {
|
||||
t.Errorf("%s was taken in prefs: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := validUserJS(mark + ")\nuser_pref(\"calendar.alarms.show\", true);\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// noPackage answers pacman as a machine without the community package.
|
||||
func (f *fake) noPackage() {
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" {
|
||||
return Output{Code: 1}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
}
|
||||
|
||||
// userPrefLines are the user_pref lines of a user.js, by preference name.
|
||||
func userPrefLines(t *testing.T, text string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, "user_pref(") {
|
||||
continue
|
||||
}
|
||||
if !statement.MatchString(line) {
|
||||
t.Errorf("not a statement Betterbird reads as one: %s", line)
|
||||
}
|
||||
out[line[len(`user_pref("`):strings.Index(line, `",`)]] = line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestADeclaredAccountTheProfileDoesNotHoldIsCreatedUnderTheModulesKeysAndListed(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(personal)
|
||||
// Betterbird is running: the user.js is placed, the list waits for its next start.
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "running") {
|
||||
t.Fatalf("the list is never written while Betterbird runs: %+v", u)
|
||||
}
|
||||
if len(u.Each) != 1 || u.Each[0].How != created || u.Each[0].Keys != (keySet{"account_mesh_personal", "id_mesh_personal",
|
||||
"server_mesh_personal", "smtp_mesh_personal"}) {
|
||||
t.Fatalf("%+v", u.Each)
|
||||
}
|
||||
placed := f.read(profileDir + "/user.js")
|
||||
for name := range userPrefLines(t, placed) {
|
||||
if key := strings.Split(name, ".")[2]; !strings.HasSuffix(key, "mesh_personal") {
|
||||
t.Errorf("a created account's preference outside the module's keys: %s", name)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(name), "password") || strings.Contains(name, "directory") {
|
||||
t.Errorf("the module writes no credential and none of Betterbird's own preferences: %s", name)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`user_pref("mail.server.server_mesh_personal.port", 993);`,
|
||||
`user_pref("mail.server.server_mesh_personal.socketType", 3);`,
|
||||
`user_pref("mail.smtpserver.smtp_mesh_personal.try_ssl", 2);`,
|
||||
`user_pref("mail.identity.id_mesh_personal.useremail", "me@mail.example.org");`} {
|
||||
if !strings.Contains(placed, want) {
|
||||
t.Errorf("the user.js lacks %s:\n%s", want, placed)
|
||||
}
|
||||
}
|
||||
|
||||
// Quit: the list gains the module's keys at its end, every other line as it was.
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := f.read(profileDir + "/prefs.js")
|
||||
if !strings.Contains(after, `user_pref("mail.accountmanager.accounts", "account1,account3,account2,account_mesh_personal");`) ||
|
||||
!strings.Contains(after, `user_pref("mail.smtpservers", "smtp1,smtp_mesh_personal");`) {
|
||||
t.Fatalf("the lists:\n%s", after)
|
||||
}
|
||||
bl, al := strings.Split(before, "\n"), strings.Split(after, "\n")
|
||||
if len(bl) != len(al) {
|
||||
t.Fatalf("lines were added or lost: %d then %d", len(bl), len(al))
|
||||
}
|
||||
for i := range bl {
|
||||
if bl[i] != al[i] && !strings.Contains(bl[i], "mail.accountmanager.accounts") && !strings.Contains(bl[i], "mail.smtpservers") {
|
||||
t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i])
|
||||
}
|
||||
}
|
||||
|
||||
// The record lost: an account under the module's own keys is still its creation, never adopted.
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
u, err = f.userJS(true)
|
||||
if err != nil || u.Each[0].How != created {
|
||||
t.Fatalf("without the record, the module's keys still say created: %+v %v", u.Each, err)
|
||||
}
|
||||
|
||||
// The account leaves the setting: only the module's keys go, the mail stays in the profile.
|
||||
f.declareAccounts(`{"accounts": {}}`)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.read(profileDir + "/prefs.js"); got != before {
|
||||
t.Fatalf("removing the module's keys left the file otherwise as it was:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// adoptable is a declaration of the profile's account1 (other@mail.example.org): equal but for its outgoing
|
||||
// port, said explicitly, and its incoming user.
|
||||
const adoptable = `{"accounts": {
|
||||
"mail": {"address": "Other@mail.example.org",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "oauth", "user": "other2"},
|
||||
"outgoing": {"host": "smtp.example.org", "port": 465, "security": "tls", "sign-in": "oauth", "user": "other"}}}}`
|
||||
|
||||
func TestADeclaredAccountTheProfileHoldsIsAdoptedUnderItsOwnKeysAndTheListIsLeftAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(u.Each) != 1 || u.Each[0].How != adopted || u.Each[0].Keys != (keySet{"account1", "id1", "server1", "smtp1"}) {
|
||||
t.Fatalf("adopted under the profile's keys: %+v", u.Each)
|
||||
}
|
||||
// The address's case and the user name differ; the default port declared explicitly is no change. Names
|
||||
// only, no value.
|
||||
if strings.Join(u.Each[0].Changed, ",") != "mail.identity.id1.useremail,mail.server.server1.userName" {
|
||||
t.Fatalf("the changed preferences, by name: %v", u.Each[0].Changed)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != before || u.Lists != "unchanged" {
|
||||
t.Fatalf("an adopted account's keys are in the lists already: nothing written to prefs.js (%s)", u.Lists)
|
||||
}
|
||||
lines := userPrefLines(t, f.read(profileDir+"/user.js"))
|
||||
for name := range lines {
|
||||
key := strings.Split(name, ".")[2]
|
||||
if key != "account1" && key != "id1" && key != "server1" && key != "smtp1" {
|
||||
t.Errorf("an adopted account's preference outside its keys: %s", name)
|
||||
}
|
||||
for _, never := range []string{"directory", ".type", "smtpServer", "identities", ".server"} {
|
||||
if strings.HasSuffix(name, never) {
|
||||
t.Errorf("the module wrote %s, which is Betterbird's or the account's own", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines["mail.server.server1.userName"], `"other2"`) {
|
||||
t.Fatalf("the declared value wins at every start: %v", lines)
|
||||
}
|
||||
// The status says it by place and keys, never by address, host or name.
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(s.Accounts)
|
||||
if s.Accounts.InList != 1 || s.Accounts.Others != 1 || strings.Contains(string(raw), "example") ||
|
||||
strings.Contains(string(raw), `"mail"`) || !strings.Contains(string(raw), `"account1"`) {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
|
||||
// Leaving the setting releases it: its lines and its record go, the account and the lists stay.
|
||||
f.declareAccounts(`{"accounts": {}}`)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != before || strings.Contains(f.read(profileDir+"/user.js"), "server1") ||
|
||||
strings.Contains(f.read(testHome+"/"+recordFile), "account1") {
|
||||
t.Fatal("a released account stays the person's, with no line or record of the module's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAmbiguousOwlsOrOtherKindOfMatchIsRefusedForThatAccountAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// A second account with account1's address.
|
||||
prefs := f.read(profileDir + "/prefs.js")
|
||||
prefs = strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account4"`, 1) +
|
||||
`user_pref("mail.account.account4.server", "server4");` + "\n" +
|
||||
`user_pref("mail.account.account4.identities", "id4");` + "\n" +
|
||||
`user_pref("mail.identity.id4.useremail", "other@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id4.smtpServer", "smtp4");` + "\n" +
|
||||
`user_pref("mail.smtpserver.smtp4.hostname", "smtp.example.org");` + "\n" +
|
||||
`user_pref("mail.server.server4.type", "imap");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
both := strings.Replace(adoptable, `{"accounts": {`, `{"accounts": {
|
||||
"zz": {"address": "new@mail.example.org",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},`, 1)
|
||||
f.declareAccounts(both)
|
||||
err := f.Prepare()
|
||||
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 2: 2 accounts in the profile have its address (account1, account4)") ||
|
||||
!strings.Contains(err.Error(), "key") || strings.Contains(err.Error(), "example.org") {
|
||||
t.Fatalf("refused by place, with the matches' keys and the remedy, never the address: %v", err)
|
||||
}
|
||||
u, _ := f.userJS(false)
|
||||
if u.Each[0].How != refused || u.Each[1].How != created {
|
||||
t.Fatalf("the other declared account goes ahead: %+v", u.Each)
|
||||
}
|
||||
if strings.Contains(f.read(profileDir+"/user.js"), "server1") || strings.Contains(f.read(profileDir+"/user.js"), "server4") {
|
||||
t.Fatal("a refused account gets no line")
|
||||
}
|
||||
|
||||
// The node's layer names the key: that one is adopted.
|
||||
f.declareAccounts(strings.Replace(both, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if u, _ := f.userJS(false); u.Each[0].How != adopted || u.Each[0].Keys.Account != "account4" {
|
||||
t.Fatalf("%+v", u.Each)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
for key, want := range map[string]string{"account9": "not in the profile's account list", "account3": "Owl's",
|
||||
"account2": "another address"} {
|
||||
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "`+key+`",`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Owl's account at the address, and an account of another kind than declared.
|
||||
f.declareAccounts(`{"accounts": {"work": {"address": "work@corp.example.com",
|
||||
"incoming": {"kind": "imap", "host": "outlook.example.com", "security": "tls", "sign-in": "oauth"},
|
||||
"outgoing": {"host": "smtp.example.com", "security": "tls", "sign-in": "oauth"}}}}`)
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "Owl's") {
|
||||
t.Errorf("Owl's account is never adopted: %v", err)
|
||||
}
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.server.server4.type", "imap"`, `"mail.server.server4.type", "pop3"`, 1))
|
||||
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "never converts") {
|
||||
t.Errorf("an account of another kind is refused, not converted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
links(t, nil)
|
||||
f.declareAccounts(`{"accounts": {
|
||||
"personal": {"address": "me@mail.example.org", "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},
|
||||
"google": {"address": "me@gmail.com", "incoming": {"kind": "imap", "host": "imap.gmail.com", "security": "tls", "sign-in": "oauth"},
|
||||
"outgoing": {"host": "smtp.gmail.com", "security": "tls", "sign-in": "oauth"}},
|
||||
"lan": {"address": "me@lan.example.org", "incoming": {"kind": "pop3", "host": "pop.example.org", "security": "tls", "sign-in": "kerberos"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "none"}}}}`)
|
||||
// The username and password fields are encrypted and never read; a fixture holding junk there proves it.
|
||||
f.write(profileDir+"/logins.json", `{"logins": [
|
||||
{"hostname": "imap://imap.example.org", "httpRealm": "imap://imap.example.org", "encryptedUsername": "MEIE...", "encryptedPassword": "MDoE..."},
|
||||
{"hostname": "smtp://smtp.example.org", "encryptedUsername": "x", "encryptedPassword": "y"}]}`)
|
||||
c := f.countAccounts(filepath.Join(f.Root, profileDir), "~/.thunderbird/60016jsp.default-default")
|
||||
if c.Declared != 3 || c.WithoutSignIn != 1 || c.Others != 2 || c.InList != 0 {
|
||||
t.Fatalf("three declared, the OAuth one not signed in, the person's and Owl's other (local folders not counted): %+v", c)
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
if strings.Contains(string(raw), "example") || strings.Contains(string(raw), "gmail") {
|
||||
t.Fatalf("a count names nothing: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0329 §1, §10, §12, §13: betterbird_accounts lists the accounts with their values, Owl's by name and address
|
||||
// and marked; its propose carries the mesh-wide layer as it stands with accounts in it, and answers the value.
|
||||
func TestBetterbirdAccountsListsTheValuesAndProposesThemInTheLayerAsItStands(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"imap://imap.example.org","encryptedUsername":"MDoEEPgAAAAA","encryptedPassword":"hunter2secret"}]}`)
|
||||
links(t, nil)
|
||||
a, err := f.ProfileAccounts(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
raw, _ := json.Marshal(credentialChecked(a))
|
||||
for _, shown := range []string{"other@mail.example.org", "imap.example.org", "smtp.example.org", `"port":993`, `"work@corp.example.com"`} {
|
||||
if !strings.Contains(string(raw), shown) {
|
||||
t.Errorf("the answer shows the operator's own %s (ADR 0329 §1): %s", shown, raw)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"hunter2", "MDoEEP"} {
|
||||
if strings.Contains(string(raw), never) {
|
||||
t.Errorf("the answer carries the login store's %q", never)
|
||||
}
|
||||
}
|
||||
for _, l := range a.Accounts {
|
||||
if l.Key == "account3" && (!l.Owl || l.Value != nil || l.Address != "work@corp.example.com") {
|
||||
t.Errorf("Owl's account is listed by name and address, marked, never as a value to declare: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
var asked []map[string]any
|
||||
asker = func(key string, body any) (json.RawMessage, error) {
|
||||
if key != "seat:mesh-controller.settings" {
|
||||
t.Fatalf("asked %s", key)
|
||||
}
|
||||
args := body.(map[string]any)
|
||||
asked = append(asked, args)
|
||||
if args["propose"] == nil {
|
||||
return json.RawMessage(`{"ok": true, "output": "{\n \"tags\": {\"$label1\": {\"name\": \"Important\"}}\n}\n approved by the operator\nbetterbird on the whole mesh, every value"}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"ok": true, "output": "proposed p-17 for betterbird on the whole mesh: accounts (other@mail.example.org); fingerprint ab12"}`), nil
|
||||
}
|
||||
t.Cleanup(func() { asker = nil })
|
||||
a, err = f.ProfileAccounts(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 2 || asked[1]["module"] != "betterbird" || asked[1]["propose"] != "true" || asked[1]["node"] != nil ||
|
||||
asked[1]["replace"] != nil {
|
||||
t.Fatalf("the layer read, then proposed for the whole mesh, never set, never removing: %v", asked)
|
||||
}
|
||||
var layer map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &layer); err != nil || string(layer["tags"]) == "" {
|
||||
t.Fatalf("the proposal carries the layer as it stands: %v %s", err, asked[1]["values"])
|
||||
}
|
||||
value, err := parseAccounts([]byte(asked[1]["values"].(string)))
|
||||
if err != nil {
|
||||
t.Fatalf("the proposed value is one the module takes: %v", err)
|
||||
}
|
||||
other, ok := value["mail"]
|
||||
if len(value) != 1 || !ok || other.Address != "other@mail.example.org" || other.Incoming.SignIn != "oauth" ||
|
||||
other.Incoming.Port != 993 || other.Outgoing.Security != "tls" || other.Outgoing.Port != 465 {
|
||||
t.Fatalf("the value holds the person's account and not Owl's: %+v", value)
|
||||
}
|
||||
if a.Derived["mail"].Address != "other@mail.example.org" {
|
||||
t.Fatalf("the answer shows the value it proposed (ADR 0329 §10): %+v", a.Derived)
|
||||
}
|
||||
if strings.Contains(a.Proposed, "other@") || !strings.Contains(a.Proposed, "p-17") {
|
||||
t.Fatalf("the controller's words come back with every address masked: %s", a.Proposed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedAccountNeverWritesIntoAnOutgoingServerAnotherIdentityUses(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// A second account of the person's, not declared, sends through smtp1 too.
|
||||
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"account1,account3,account2"`, `"account1,account3,account2,account5"`, 1) +
|
||||
`user_pref("mail.account.account5.server", "server5");` + "\n" +
|
||||
`user_pref("mail.account.account5.identities", "id5");` + "\n" +
|
||||
`user_pref("mail.identity.id5.useremail", "fifth@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id5.smtpServer", "smtp1");` + "\n" +
|
||||
`user_pref("mail.server.server5.type", "imap");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
f.declareAccounts(adoptable)
|
||||
err := f.Prepare()
|
||||
if err == nil || !strings.Contains(err.Error(), "also used by identity id5") || strings.Contains(err.Error(), "example.org") {
|
||||
t.Fatalf("refused with the remedy, by keys: %v", err)
|
||||
}
|
||||
if user, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); strings.Contains(string(user), "smtpserver.smtp1") {
|
||||
t.Fatal("the shared outgoing server was written")
|
||||
}
|
||||
|
||||
// Recorded adoption, then the person moves the identity to a server of its own: the module follows it.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.identity.id5.smtpServer", "smtp1"`, `"mail.identity.id5.smtpServer", "smtp5"`, 1))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.identity.id1.smtpServer", "smtp1"`, `"mail.identity.id1.smtpServer", "smtp9"`, 1) +
|
||||
`user_pref("mail.smtpserver.smtp9.hostname", "smtp.example.org");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", moved)
|
||||
u, err := f.userJS(true)
|
||||
if err != nil || u.Each[0].How != adopted || u.Each[0].Keys.SMTP != "smtp9" {
|
||||
t.Fatalf("the identity's current outgoing server is followed: %+v %v", u.Each, err)
|
||||
}
|
||||
user := f.read(profileDir + "/user.js")
|
||||
if strings.Contains(user, "smtpserver.smtp1.") || !strings.Contains(user, "smtpserver.smtp9.hostname") {
|
||||
t.Fatalf("never the old one:\n%s", user)
|
||||
}
|
||||
// And the move back onto a shared server is refused again.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(moved, `"mail.identity.id5.smtpServer", "smtp5"`, `"mail.identity.id5.smtpServer", "smtp9"`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id5") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSAnswerWithholdsTheAccountsValues(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("mail.compose.default_to", "x");`)
|
||||
f.declareAccounts(personal)
|
||||
u, err := f.userJS(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, never := range []string{"example.org", `"me"`, `"Me"`} {
|
||||
if strings.Contains(u.Content, never) {
|
||||
t.Errorf("the answer carries %s:\n%s", never, u.Content)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(u.Content, `user_pref("mail.server.server_mesh_personal.hostname", "<withheld>");`) {
|
||||
t.Fatalf("an account's preference is named and its value withheld:\n%s", u.Content)
|
||||
}
|
||||
// What is placed in the profile keeps the values: only the answer withholds them.
|
||||
if f.read(testHome+"/"+accountsFile) == "" {
|
||||
t.Fatal("no declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialRefusalNamesNoAccount(t *testing.T) {
|
||||
_, err := parseAccounts([]byte(`{"accounts": {"personal": {"incoming": {"password": "hunter2"}}}}`))
|
||||
if err == nil || strings.Contains(err.Error(), "personal") || !strings.Contains(err.Error(), "accounts.<an account>.incoming.password") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// account1 has an alias identity on smtp1; smtp1 is the default server; Owl's account and local folders
|
||||
// have identities that name no outgoing server.
|
||||
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.account.account1.identities", "id1"`, `"mail.account.account1.identities", "id1,id1b"`, 1)
|
||||
prefs = strings.Replace(prefs, `"mail.account.account2.server", "server2");`, `"mail.account.account2.server", "server2");`+"\n"+
|
||||
`user_pref("mail.account.account2.identities", "id2");`, 1) +
|
||||
`user_pref("mail.identity.id1b.useremail", "alias@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id1b.smtpServer", "smtp1");` + "\n" +
|
||||
`user_pref("mail.identity.id2.useremail", "local@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.smtp.defaultserver", "smtp1");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
f.declareAccounts(adoptable)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatalf("an account's own alias, and identities that do not send by SMTP, are no sharing: %v", err)
|
||||
}
|
||||
// An identity of another mail account naming no server does use the default one.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account6"`, 1)+
|
||||
`user_pref("mail.account.account6.server", "server6");`+"\n"+
|
||||
`user_pref("mail.account.account6.identities", "id6");`+"\n"+
|
||||
`user_pref("mail.identity.id6.useremail", "six@mail.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server6.type", "pop3");`+"\n")
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id6") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review 121: the account lists wait for Betterbird to close by its profile's lock too, not only by its process.
|
||||
func TestTheAccountListsWaitWhileTheProfileIsLocked(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(personal)
|
||||
stopBetterbird(t, f)
|
||||
f.write(profileDir+"/lock", "")
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "locked") || f.read(profileDir+"/prefs.js") != before {
|
||||
t.Fatalf("with the profile locked and no Betterbird running, prefs.js is unchanged: %q", u.Lists)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, profileDir, "lock"))
|
||||
if u, err = f.userJS(true); err != nil || !strings.HasPrefix(u.Lists, "written") {
|
||||
t.Fatalf("once unlocked the list is written: %q %v", u.Lists, err)
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package main
|
||||
|
||||
// Which account of the profile a declared account is (novox/hq ADR 0317): adopted, created or refused.
|
||||
//
|
||||
// At every apply, per declared account in its place in the setting (§1):
|
||||
//
|
||||
// 1. an account the module recorded on this node for this profile, adopted or created, is kept by its
|
||||
// recorded keys without matching again, while its account key is still in the profile's account list;
|
||||
// 2. otherwise an account under the module's own mesh_<name> keys is the module's creation;
|
||||
// 3. otherwise a node's layer may name the key to adopt (§8): adopted only when it is in the account list,
|
||||
// its default identity has the declared address and it is not Owl's;
|
||||
// 4. otherwise the profile's accounts whose default identity has the declared address: one is adopted, none
|
||||
// means the account is created under mesh_<name> keys (ADR 0316 §11).
|
||||
//
|
||||
// Refused, with the remedy, for that account alone (§7): two or more accounts with the address, the one that
|
||||
// has it being Owl's, a named key that does not qualify, an account of another kind than the declaration says
|
||||
// (the module never converts one: the review of ADR 0317), and an identity that names no outgoing server. The
|
||||
// other declared accounts go ahead.
|
||||
//
|
||||
// Adopting takes the account's keys as the module's (§2): its account key, its default identity, its incoming
|
||||
// server and the outgoing server that identity names. The account lists are left as they are, because the keys
|
||||
// are in them already; the declared fields are written under those keys (§4, §5). What was adopted or created
|
||||
// is recorded in the module's directory in the home, by the tools and never by a setting (§3); leaving the
|
||||
// setting releases an adopted account (its lines and its record go, the account stays the person's, §6).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// recordFile is where the module records, per profile, what it adopted or created (ADR 0317 §3).
|
||||
const recordFile = ".config/betterbird/accounts-record.json"
|
||||
|
||||
// How a declared account resolved.
|
||||
const (
|
||||
adopted = "adopted"
|
||||
created = "created"
|
||||
refused = "refused"
|
||||
)
|
||||
|
||||
// keySet are an account's four keys.
|
||||
type keySet struct {
|
||||
Account string `json:"account"`
|
||||
Identity string `json:"identity"`
|
||||
Server string `json:"server"`
|
||||
SMTP string `json:"smtp"`
|
||||
}
|
||||
|
||||
// resolvedAccount is one declared account as it resolved, said by its place and keys, never its name, address
|
||||
// or host (ADR 0316 §16, ADR 0317 §3).
|
||||
type resolvedAccount struct {
|
||||
Place int `json:"place"`
|
||||
How string `json:"how"`
|
||||
Keys keySet `json:"keys,omitempty"`
|
||||
// Why is a refusal's reason and its remedy.
|
||||
Why string `json:"why,omitempty"`
|
||||
// Changed are the preferences an apply changed for it, by name only (ADR 0317 §4).
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
name string
|
||||
}
|
||||
|
||||
// record is what the module adopted or created, per profile (its directory as the tools say it), per
|
||||
// declared account's name.
|
||||
type record map[string]map[string]recorded
|
||||
|
||||
type recorded struct {
|
||||
How string `json:"how"`
|
||||
Keys keySet `json:"keys"`
|
||||
}
|
||||
|
||||
func (m *Machine) readRecord(profileDir string) map[string]recorded {
|
||||
raw, err := readBounded(m.home(recordFile))
|
||||
if err != nil {
|
||||
return map[string]recorded{}
|
||||
}
|
||||
var r record
|
||||
if json.Unmarshal(raw, &r) != nil || r[profileDir] == nil {
|
||||
return map[string]recorded{}
|
||||
}
|
||||
return r[profileDir]
|
||||
}
|
||||
|
||||
// writeRecord keeps what this apply resolved for the profile: a refused account is not recorded, and an
|
||||
// account no longer declared is dropped (§6).
|
||||
func (m *Machine) writeRecord(profileDir string, resolved []resolvedAccount) error {
|
||||
r := record{}
|
||||
if raw, err := readBounded(m.home(recordFile)); err == nil {
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
}
|
||||
mine := map[string]recorded{}
|
||||
for _, a := range resolved {
|
||||
if a.How != refused {
|
||||
mine[a.name] = recorded{How: a.How, Keys: a.Keys}
|
||||
}
|
||||
}
|
||||
if len(mine) == 0 {
|
||||
delete(r, profileDir)
|
||||
} else {
|
||||
r[profileDir] = mine
|
||||
}
|
||||
body, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := m.home(recordFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-new"
|
||||
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAccounts resolves every declared account, in its place in the setting (the file's order, by name).
|
||||
func resolveAccounts(accounts map[string]Account, prefs map[string]string, rec map[string]recorded) []resolvedAccount {
|
||||
have := profileAccounts(prefs)
|
||||
inList := map[string]profileAccount{}
|
||||
for _, a := range have {
|
||||
inList[a.Key] = a
|
||||
}
|
||||
names := sortedNames(accounts)
|
||||
taken := map[string]bool{}
|
||||
var out []resolvedAccount
|
||||
for i, name := range names {
|
||||
r := resolveOne(i+1, len(names), name, accounts[name], prefs, have, inList, rec, taken)
|
||||
r.name = name
|
||||
if r.How != refused {
|
||||
taken[r.Keys.Account] = true
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return refuseSharedOutgoing(out, prefs, have)
|
||||
}
|
||||
|
||||
// refuseSharedOutgoing refuses an adopted account whose outgoing server another identity uses that is neither
|
||||
// adopted nor the module's (review 34 of mesh-catalog #232): Betterbird lets identities share an outgoing
|
||||
// server, and the declared values written into it would change that other account's sending at every start.
|
||||
// ADR 0317 §2 adopts the outgoing server the identity names and leaves the lists as they are, so the module
|
||||
// does not give the identity a server of its own; it refuses, with the remedy.
|
||||
func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, have []profileAccount) []resolvedAccount {
|
||||
// Every identity of an adopted or created account is its own, its aliases too (review 36).
|
||||
accountOf := map[string]profileAccount{}
|
||||
kindOf := map[string]string{}
|
||||
for _, a := range have {
|
||||
accountOf[a.Key] = a
|
||||
for _, id := range a.Identities {
|
||||
kindOf[id] = a.Kind
|
||||
}
|
||||
}
|
||||
ours := map[string]bool{}
|
||||
for _, r := range resolved {
|
||||
if r.How == refused {
|
||||
continue
|
||||
}
|
||||
ours[r.Keys.Identity] = true
|
||||
for _, id := range accountOf[r.Keys.Account].Identities {
|
||||
ours[id] = true
|
||||
}
|
||||
}
|
||||
defaultServer := unquote(prefs["mail.smtp.defaultserver"])
|
||||
users := map[string][]string{}
|
||||
seen := map[string]bool{}
|
||||
use := func(id string) {
|
||||
// An identity the module created for a declared account sends as that account does (ADR 0329 §7).
|
||||
if seen[id] || ours[id] || ownIdentity.MatchString(id) {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
server := unquote(prefs["mail.identity."+id+".smtpServer"])
|
||||
// Only a mail account's identity sends through the default server when it names none: Owl's, local
|
||||
// folders and feeds do not send by SMTP (review 36).
|
||||
if server == "" && (kindOf[id] == "imap" || kindOf[id] == "pop3") {
|
||||
server = defaultServer
|
||||
}
|
||||
if server != "" {
|
||||
users[server] = append(users[server], id)
|
||||
}
|
||||
}
|
||||
for _, a := range have {
|
||||
for _, id := range a.Identities {
|
||||
use(id)
|
||||
}
|
||||
}
|
||||
for k := range prefs {
|
||||
if strings.HasPrefix(k, "mail.identity.") && strings.HasSuffix(k, ".smtpServer") {
|
||||
use(strings.TrimSuffix(strings.TrimPrefix(k, "mail.identity."), ".smtpServer"))
|
||||
}
|
||||
}
|
||||
for i, r := range resolved {
|
||||
if r.How != adopted {
|
||||
continue
|
||||
}
|
||||
if others := users[r.Keys.SMTP]; len(others) > 0 {
|
||||
sort.Strings(others)
|
||||
resolved[i] = resolvedAccount{Place: r.Place, How: refused, name: r.name, Why: fmt.Sprintf(
|
||||
"declared account %d of %d: the outgoing server %s of the profile's account %s is also used by "+
|
||||
"identity %s, whose sending the declared values would change: give the account an outgoing "+
|
||||
"server of its own in Betterbird's account settings, then start Betterbird again",
|
||||
r.Place, len(resolved), r.Keys.SMTP, r.Keys.Account, strings.Join(others, ", "))}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveOne(place, of int, name string, a Account, prefs map[string]string, have []profileAccount,
|
||||
inList map[string]profileAccount, rec map[string]recorded, taken map[string]bool) resolvedAccount {
|
||||
r := resolvedAccount{Place: place}
|
||||
refuse := func(format string, args ...any) resolvedAccount {
|
||||
r.How, r.Keys = refused, keySet{}
|
||||
r.Why = fmt.Sprintf("declared account %d of %d: ", place, of) + fmt.Sprintf(format, args...)
|
||||
return r
|
||||
}
|
||||
// 1. Recorded, while its account is still in the list.
|
||||
if was, ok := rec[name]; ok {
|
||||
if _, there := inList[was.Keys.Account]; there || was.How == created {
|
||||
r.How, r.Keys = was.How, was.Keys
|
||||
if was.How == adopted {
|
||||
// The identity's outgoing server as it is now: a person may have moved it since.
|
||||
smtpKey := unquote(prefs["mail.identity."+was.Keys.Identity+".smtpServer"])
|
||||
if smtpKey == "" {
|
||||
return refuse("the profile's account %s has an identity that names no outgoing server: choose one "+
|
||||
"for it in Betterbird's account settings, then start Betterbird again", was.Keys.Account)
|
||||
}
|
||||
r.Keys.SMTP = smtpKey
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
// 2. Under the module's own keys: its creation.
|
||||
account, identity, server, smtp := keysOf(name)
|
||||
own := keySet{account, identity, server, smtp}
|
||||
if _, there := inList[account]; there {
|
||||
r.How, r.Keys = created, own
|
||||
return r
|
||||
}
|
||||
adopt := func(pa profileAccount) resolvedAccount {
|
||||
if pa.Kind != a.Incoming.Kind {
|
||||
return refuse("the profile's account %s is of kind %s and the declaration says %s: the module never "+
|
||||
"converts an account. Declare the kind the account has, or remove the account in Betterbird",
|
||||
pa.Key, pa.Kind, a.Incoming.Kind)
|
||||
}
|
||||
smtpKey := unquote(prefs["mail.identity."+pa.Identities[0]+".smtpServer"])
|
||||
if smtpKey == "" {
|
||||
return refuse("the profile's account %s has an identity that names no outgoing server: choose one for it "+
|
||||
"in Betterbird's account settings, then start Betterbird again", pa.Key)
|
||||
}
|
||||
r.How, r.Keys = adopted, keySet{pa.Key, pa.Identities[0], pa.Server, smtpKey}
|
||||
return r
|
||||
}
|
||||
matchesDefault := func(pa profileAccount) bool {
|
||||
return len(pa.Addresses) > 0 && strings.EqualFold(pa.Addresses[0], a.Address)
|
||||
}
|
||||
// 3. The key a node's layer names.
|
||||
if a.Key != "" {
|
||||
pa, there := inList[a.Key]
|
||||
switch {
|
||||
case !there:
|
||||
return refuse("the key %s this node's layer names is not in the profile's account list", a.Key)
|
||||
case pa.Owl:
|
||||
return refuse("the key %s this node's layer names is Owl's account, which is never declared "+
|
||||
"(novox/hq ADR 0316 §13)", a.Key)
|
||||
case !matchesDefault(pa):
|
||||
return refuse("the key %s this node's layer names is an account whose default identity has another "+
|
||||
"address", a.Key)
|
||||
case taken[pa.Key]:
|
||||
return refuse("the key %s is already another declared account's", a.Key)
|
||||
}
|
||||
return adopt(pa)
|
||||
}
|
||||
// 4. By the default identity's address.
|
||||
var matches []profileAccount
|
||||
for _, pa := range have {
|
||||
if !pa.Ours && !taken[pa.Key] && matchesDefault(pa) {
|
||||
matches = append(matches, pa)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(matches) == 0:
|
||||
r.How, r.Keys = created, own
|
||||
return r
|
||||
case len(matches) > 1:
|
||||
var keys []string
|
||||
for _, pa := range matches {
|
||||
keys = append(keys, pa.Key)
|
||||
}
|
||||
return refuse("%d accounts in the profile have its address (%s): remove the duplicate in Betterbird, or "+
|
||||
"give the one to adopt as key in this node's layer of the setting accounts (novox/hq ADR 0317 §7, §8)",
|
||||
len(matches), strings.Join(keys, ", "))
|
||||
case matches[0].Owl:
|
||||
return refuse("the profile's account with its address, %s, is Owl's, which is never declared (novox/hq "+
|
||||
"ADR 0316 §13): take it out of the setting accounts", matches[0].Key)
|
||||
}
|
||||
return adopt(matches[0])
|
||||
}
|
||||
|
||||
// changedPrefs are the preferences of a resolved account whose declared value differs from the profile's
|
||||
// saved one (prefs.js), by name only (ADR 0317 §4). A preference prefs.js does not hold is changed unless the
|
||||
// declared value is what Betterbird reads in its place.
|
||||
func changedPrefs(r resolvedAccount, a Account, saved map[string]string) []string {
|
||||
defaults := map[string]string{}
|
||||
if r.How == adopted {
|
||||
// What Betterbird reads when prefs.js holds no value: the same defaults betterbird_accounts reads with.
|
||||
pa := profileAccount{Key: r.Keys.Account, Server: r.Keys.Server, Kind: a.Incoming.Kind,
|
||||
Identities: []string{r.Keys.Identity}}
|
||||
view := readAccount(saved, pa)
|
||||
for _, p := range declaredPrefs(r.Keys, view, true) {
|
||||
defaults[p[0]] = p[1]
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, p := range declaredPrefs(r.Keys, a, r.How == adopted) {
|
||||
have, ok := saved[p[0]]
|
||||
if !ok {
|
||||
have, ok = defaults[p[0]]
|
||||
}
|
||||
if !ok || have != p[1] {
|
||||
out = append(out, p[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,276 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const profileDir = testHome + "/.thunderbird/60016jsp.default-default"
|
||||
|
||||
// links stands in for the kernel's fd links: path → target.
|
||||
func links(t *testing.T, m map[string]string) {
|
||||
t.Helper()
|
||||
old := readLink
|
||||
readLink = func(p string) (string, error) {
|
||||
for k, v := range m {
|
||||
if strings.HasSuffix(p, k) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
t.Cleanup(func() { readLink = old })
|
||||
}
|
||||
|
||||
// betterbird is a running Betterbird with one child, its profile and its prefs.
|
||||
func (f *fake) betterbird() {
|
||||
f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1")
|
||||
f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope")
|
||||
f.write("/proc/4000/fd/40", "")
|
||||
f.write("/proc/4000/fd/41", "")
|
||||
f.write("/proc/4000/fd/1", "")
|
||||
f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+
|
||||
"[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n")
|
||||
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
|
||||
`user_pref("browser.display.use_system_colors", true);`+"\n"+
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
|
||||
`user_pref("calendar.alarms.showmissed", true);`+"\n"+
|
||||
`user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+
|
||||
`user_pref("extensions.owl.token", "abc");`+"\n"+
|
||||
`user_pref("signon.rememberSignons", true);`+"\n")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
|
||||
}
|
||||
|
||||
// declare writes the setting prefs as the node-engine does (prefs.json, novox/hq issue 586), from user_pref
|
||||
// statements: each one entry of the map.
|
||||
func (f *fake) declare(prefs string) {
|
||||
f.t.Helper()
|
||||
values := map[string]any{}
|
||||
for name, lit := range statementsOf([]byte(prefs)) {
|
||||
v, ok := prefValue(lit)
|
||||
if !ok {
|
||||
f.t.Fatalf("%s: %s is not a value the setting holds", name, lit)
|
||||
}
|
||||
values[name] = v
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefs": values})
|
||||
f.write(testHome+"/"+prefsFile, string(body))
|
||||
}
|
||||
|
||||
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"})
|
||||
p, err := f.profile()
|
||||
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") {
|
||||
t.Fatalf("%+v %v", p, err)
|
||||
}
|
||||
links(t, map[string]string{})
|
||||
p, err = f.profile()
|
||||
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") {
|
||||
t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n")
|
||||
got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := got["prefs"].([]Pref)
|
||||
by := map[string]Pref{}
|
||||
for _, p := range ps {
|
||||
by[p.Name] = p
|
||||
}
|
||||
if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") {
|
||||
t.Fatalf("user.js wins: %+v", p)
|
||||
}
|
||||
for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} {
|
||||
if p := by[n]; p.Value != "" || p.Withheld == "" {
|
||||
t.Errorf("%s: %+v", n, p)
|
||||
}
|
||||
}
|
||||
if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
if _, err := f.Prefs(nil); err == nil {
|
||||
t.Fatal("names are required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") {
|
||||
t.Fatalf("nothing declared: %v", err)
|
||||
}
|
||||
f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`)
|
||||
u, err := f.userJS(true)
|
||||
if err != nil || !u.Applied || u.Statements != 2 {
|
||||
t.Fatalf("%+v %v", u, err)
|
||||
}
|
||||
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
|
||||
declared, _, _ := f.declaredText()
|
||||
if string(placed) != declared || !strings.Contains(declared, `user_pref("ui.key.x", "a;b");`) {
|
||||
t.Fatalf("placed whole, as declared:\n%s", placed)
|
||||
}
|
||||
if !strings.HasPrefix(u.Effective, "yes") {
|
||||
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
|
||||
}
|
||||
later := time.Unix(1_900_000_000, 0)
|
||||
os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later)
|
||||
if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") {
|
||||
t.Fatalf("placed after Betterbird started: %q", u.Effective)
|
||||
}
|
||||
f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n")
|
||||
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") {
|
||||
t.Fatalf("an operator's user.js is never overwritten: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" {
|
||||
t.Fatal("and it is left as it was")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) {
|
||||
for text, ok := range map[string]bool{
|
||||
mark + ")\n// none\n": true,
|
||||
mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true,
|
||||
mark + ")\n" + `pref("a.b", true);`: false,
|
||||
mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false,
|
||||
mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false,
|
||||
`user_pref("a.b", true);`: false,
|
||||
} {
|
||||
if err := validUserJS(text); (err == nil) != ok {
|
||||
t.Errorf("%q: %v", text, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"})
|
||||
f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" && args[0] == "-Q" {
|
||||
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
|
||||
}
|
||||
if name == "pacman" && args[0] == "-Qmq" {
|
||||
return Output{Stdout: "betterbird-bin\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
|
||||
s.Installation.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.Output[0].Dead {
|
||||
t.Fatalf("the session's dead pipe: %+v", s.Output)
|
||||
}
|
||||
found := false
|
||||
for _, p := range s.Display {
|
||||
if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%+v", s.Display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"})
|
||||
f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} }
|
||||
a, err := f.Check()
|
||||
if err != nil || a.OK {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
all := ""
|
||||
for _, x := range a.Findings {
|
||||
all += x.What + " | " + x.Do + "\n"
|
||||
}
|
||||
if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") {
|
||||
t.Fatal(all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.onStart = func(argv []string) {
|
||||
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
|
||||
}
|
||||
a, err := f.Restart(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("calendar.alarms.show", true);`)
|
||||
before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
|
||||
a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
dir := filepath.Join(f.Root, "/run/user/1000", testUnit)
|
||||
user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js"))
|
||||
cal, _ := os.ReadFile(filepath.Join(dir, "test.ics"))
|
||||
if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) ||
|
||||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
|
||||
t.Fatalf("%s", user)
|
||||
}
|
||||
// Betterbird's update check stays off, so no About window opens on the operator's screen, and the
|
||||
// test's own preferences come last, so nothing carried or declared overrides them.
|
||||
if !strings.Contains(string(user), `"betterbird.update.checkIntervalDays", 0`) ||
|
||||
strings.Index(string(user), "extensions.activeThemeID") > strings.Index(string(user), "calendar.registry.meshtest.uri") ||
|
||||
strings.Index(string(user), `"calendar.alarms.show"`) > strings.Index(string(user), "calendar.registry.meshtest.uri") {
|
||||
t.Fatalf("%s", user)
|
||||
}
|
||||
// The event starts 10 minutes after the reminder, which is due 40 s from now.
|
||||
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
|
||||
a.Due != "2027-01-15T08:00:40Z" {
|
||||
t.Fatalf("%s %s", cal, a.Due)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
|
||||
if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) {
|
||||
t.Fatal("the operator's profile is untouched")
|
||||
}
|
||||
if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) {
|
||||
t.Fatalf("stop removes the test profile: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
package main
|
||||
|
||||
// The configuration of Betterbird's preferences (novox/hq issue 345, issue 586), in three layers:
|
||||
//
|
||||
// - LOCKED: the enterprise policy (/opt/betterbird/distribution/policies.json). Its Preferences entries with
|
||||
// Status locked win over everything, and Betterbird's settings show them greyed out.
|
||||
// - DECLARED: the setting prefs, the operator's setup: mesh-wide, with a node's layer over it key by key. The
|
||||
// node-engine writes it to ~/.config/betterbird/prefs.json and the tools place it in the profile's user.js,
|
||||
// where it wins over prefs.js at every start.
|
||||
// - BETTERBIRD'S DEFAULT: what Betterbird starts with. The module's defaults file
|
||||
// (/opt/betterbird/defaults/pref/mesh-defaults.js) holds no value of the operator's any more (issue 586): it
|
||||
// names the preferences the module keeps at Betterbird's own default, where a value the profile saved is drift.
|
||||
//
|
||||
// betterbird_config shows each managed preference through the layers and flags the drift; betterbird_config_apply
|
||||
// clears a drifting value from prefs.js while Betterbird is not running; keep proposes a node layer of the setting
|
||||
// prefs that holds the profile's value for this node instead, through the controller's settings verb, never by
|
||||
// writing user.js.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// prefDir is the installation's own defaults directory; the module places its files there.
|
||||
prefDir = installDir + "/defaults/pref"
|
||||
// meshDefaults is the module's DEFAULT layer.
|
||||
meshDefaults = prefDir + "/mesh-defaults.js"
|
||||
// omniJa is Betterbird's packed application, holding its own default preferences.
|
||||
omniJa = installDir + "/omni.ja"
|
||||
)
|
||||
|
||||
// moduleFiles are the module's files in the installation's defaults directory: their values are the module's
|
||||
// defaults, not Betterbird's.
|
||||
var moduleFiles = map[string]bool{"mesh-defaults.js": true, "vendor-prefs.js": true}
|
||||
|
||||
var (
|
||||
// defaultLine is one pref(…) or sticky_pref(…) line of a defaults file; a comment may follow it.
|
||||
defaultLine = regexp.MustCompile(`^\s*(?:sticky_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;`)
|
||||
// keepsDefault is a line of the module's defaults naming a preference it manages without a value.
|
||||
keepsDefault = regexp.MustCompile(`^//\s*keeps Betterbird's default:\s*([A-Za-z0-9_.@{}\-]+)\s*$`)
|
||||
)
|
||||
|
||||
// builtIn are the defaults compiled into Betterbird of the preferences the module keeps at Betterbird's default,
|
||||
// which no defaults file gives: so a value saved equal to them is not drift.
|
||||
var builtIn = map[string]string{
|
||||
themePref: `"` + defaultTheme + `"`,
|
||||
"browser.display.use_system_colors": "false",
|
||||
"layout.css.always_underline_links": "false",
|
||||
}
|
||||
|
||||
const (
|
||||
themePref = "extensions.activeThemeID"
|
||||
defaultTheme = "default-theme@mozilla.org"
|
||||
// themeRemedy is how the theme comes back: Betterbird keeps the active theme in its add-on database
|
||||
// (extensions.json), and the preference only mirrors it (XPIDatabase addonChanged), so clearing it changes
|
||||
// nothing and betterbird_config_apply never touches it.
|
||||
themeRemedy = "enable the default theme in Betterbird's add-on manager (Add-ons and Themes, Themes): the active " +
|
||||
"theme is kept in the add-on database, and its preference only mirrors it, so clearing prefs.js would not change it"
|
||||
)
|
||||
|
||||
// activeTheme is the theme the profile's add-on database marks active, or "" when it cannot be read.
|
||||
func activeTheme(profile string) string {
|
||||
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var ext struct {
|
||||
Addons []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"addons"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ext) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range ext.Addons {
|
||||
if a.Type == "theme" && a.Active {
|
||||
return a.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unmanageable says why a name in the module's defaults is never managed by these tools, or "": a credential's,
|
||||
// an account's or a server's preference, which betterbird_config never answers and betterbird_config_apply never
|
||||
// clears, whatever the defaults file lists.
|
||||
func unmanageable(name string) string {
|
||||
if why := withheld(name); why != "" {
|
||||
return why
|
||||
}
|
||||
for _, p := range accountOwned {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return "an account's preference, the setting accounts' alone"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// answer is a value as these tools answer it: masked, but for the theme's id.
|
||||
func (m *Machine) answer(name, v string) string {
|
||||
if allowedExtensions[name] {
|
||||
return v
|
||||
}
|
||||
return m.maskValue(v)
|
||||
}
|
||||
|
||||
// nodeName is the machine this runtime serves, as the mesh names it: a variable so a test answers.
|
||||
var nodeName = func() string { return os.Getenv("MESH_NODE") }
|
||||
|
||||
// shipped is the module's DEFAULT layer as placed on the machine.
|
||||
type shipped struct {
|
||||
values map[string]string
|
||||
// keeps are the managed names whose module value is Betterbird's own default.
|
||||
keeps map[string]bool
|
||||
// order is every managed name, in the file's order.
|
||||
order []string
|
||||
}
|
||||
|
||||
// parseShipped reads the module's defaults file.
|
||||
func parseShipped(raw []byte) shipped {
|
||||
s := shipped{values: map[string]string{}, keeps: map[string]bool{}}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if mm := keepsDefault.FindStringSubmatch(strings.TrimSpace(line)); mm != nil {
|
||||
if !s.keeps[mm[1]] {
|
||||
s.keeps[mm[1]] = true
|
||||
s.order = append(s.order, mm[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
|
||||
if _, seen := s.values[mm[1]]; !seen {
|
||||
s.order = append(s.order, mm[1])
|
||||
}
|
||||
s.values[mm[1]] = mm[2]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// shippedDefaults reads the module's defaults file from the installation.
|
||||
func (m *Machine) shippedDefaults() (shipped, error) {
|
||||
raw, err := readBounded(m.path(meshDefaults))
|
||||
if err != nil {
|
||||
return shipped{values: map[string]string{}, keeps: map[string]bool{}}, err
|
||||
}
|
||||
return parseShipped(raw), nil
|
||||
}
|
||||
|
||||
// DefaultValue is a default preference and the file that gives it.
|
||||
type DefaultValue struct {
|
||||
Value string
|
||||
From string
|
||||
// Module says the module's files give it, not Betterbird's.
|
||||
Module bool
|
||||
}
|
||||
|
||||
// readDefaultsInto reads the pref(…) lines of the files of one place, in Gecko's order: it reads a place's
|
||||
// files last-first by name (Preferences.cpp, pref_ReadDefaultPrefs), so of a name in several files the value
|
||||
// of the file first by name holds. A later place overrides an earlier one.
|
||||
func readDefaultsInto(out map[string]DefaultValue, files map[string][]byte, label func(string) (string, bool)) {
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
place := map[string]DefaultValue{}
|
||||
for i := len(names) - 1; i >= 0; i-- {
|
||||
from, module := label(names[i])
|
||||
for _, line := range strings.Split(string(files[names[i]]), "\n") {
|
||||
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
|
||||
place[mm[1]] = DefaultValue{Value: mm[2], From: from, Module: module}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range place {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// mostDefaultsFile bounds one defaults file read from omni.ja.
|
||||
const mostDefaultsFile = 4 << 20
|
||||
|
||||
// defaults reads every default preference Betterbird starts with: greprefs.js and defaults/pref/*.js in
|
||||
// omni.ja, then the installation's defaults/pref/*.js, where the module's files are. A preference compiled into
|
||||
// Betterbird (a static preference) is in no file, and is absent here. withModule false leaves the module's own
|
||||
// files out, so what remains is Betterbird's. The error says what could not be read.
|
||||
func (m *Machine) defaults(withModule bool) (map[string]DefaultValue, error) {
|
||||
out := map[string]DefaultValue{}
|
||||
var problems []string
|
||||
if r, err := zip.OpenReader(m.path(omniJa)); err != nil {
|
||||
problems = append(problems, omniJa+" is unreadable: "+err.Error())
|
||||
} else {
|
||||
gre, app := map[string][]byte{}, map[string][]byte{}
|
||||
for _, f := range r.File {
|
||||
switch {
|
||||
case f.Name == "greprefs.js":
|
||||
case strings.HasPrefix(f.Name, "defaults/pref/") && strings.HasSuffix(f.Name, ".js") && !strings.Contains(f.Name[len("defaults/pref/"):], "/"):
|
||||
default:
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(rc, mostDefaultsFile))
|
||||
rc.Close()
|
||||
if f.Name == "greprefs.js" {
|
||||
gre[f.Name] = b
|
||||
} else {
|
||||
app[filepath.Base(f.Name)] = b
|
||||
}
|
||||
}
|
||||
r.Close()
|
||||
readDefaultsInto(out, gre, func(n string) (string, bool) { return "Betterbird's default (omni.ja " + n + ")", false })
|
||||
readDefaultsInto(out, app, func(n string) (string, bool) {
|
||||
return "Betterbird's default (omni.ja defaults/pref/" + n + ")", false
|
||||
})
|
||||
}
|
||||
installed := map[string][]byte{}
|
||||
entries, _ := os.ReadDir(m.path(prefDir))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") || (moduleFiles[e.Name()] && !withModule) {
|
||||
continue
|
||||
}
|
||||
if b, err := readBounded(m.path(filepath.Join(prefDir, e.Name()))); err == nil {
|
||||
installed[e.Name()] = b
|
||||
}
|
||||
}
|
||||
readDefaultsInto(out, installed, func(n string) (string, bool) {
|
||||
if moduleFiles[n] {
|
||||
return "the module's default (" + prefDir + "/" + n + ")", true
|
||||
}
|
||||
return "Betterbird's default (" + prefDir + "/" + n + ")", false
|
||||
})
|
||||
if len(problems) > 0 {
|
||||
return out, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// policyPrefs are the Preferences entries of the placed policy: name to its value as a preference file writes
|
||||
// it, and whether it is locked. An unreadable policy has none.
|
||||
type policyPref struct {
|
||||
Value string
|
||||
Locked bool
|
||||
}
|
||||
|
||||
func policyPrefsOf(raw []byte) map[string]policyPref {
|
||||
var p struct {
|
||||
Policies struct {
|
||||
Preferences map[string]struct {
|
||||
Value any `json:"Value"`
|
||||
Status string `json:"Status"`
|
||||
} `json:"Preferences"`
|
||||
} `json:"policies"`
|
||||
}
|
||||
out := map[string]policyPref{}
|
||||
if json.Unmarshal(raw, &p) != nil {
|
||||
return out
|
||||
}
|
||||
for name, e := range p.Policies.Preferences {
|
||||
out[name] = policyPref{Value: literal(e.Value), Locked: e.Status == "locked"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// locks are the preferences the placed policy locks: name to value.
|
||||
func (m *Machine) locks() map[string]string {
|
||||
raw, err := readBounded(m.path(policyFile))
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := map[string]string{}
|
||||
for name, p := range policyPrefsOf(raw) {
|
||||
if p.Locked {
|
||||
out[name] = p.Value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// literal is a JSON value as a preference file writes it.
|
||||
func literal(v any) string {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case string:
|
||||
return quoted(x)
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// statementsOf are the user_pref(…) statements of a user.js, several to a line allowed: name to value.
|
||||
func statementsOf(raw []byte) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
||||
if mm := prefLine.FindStringSubmatch(st); mm != nil {
|
||||
out[mm[1]] = mm[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConfigPref is one managed preference through the layers.
|
||||
type ConfigPref struct {
|
||||
Name string `json:"name"`
|
||||
// Layer is locked (the policy), declared (the setting prefs), default (a value of the module's defaults file) or
|
||||
// "Betterbird's default" (a managed preference whose module value is Betterbird's own).
|
||||
Layer string `json:"layer"`
|
||||
Locked string `json:"locked,omitempty"`
|
||||
ModuleDefault string `json:"module_default,omitempty"`
|
||||
BetterbirdDefault string `json:"betterbird_default,omitempty"`
|
||||
// Declared is what the setting prefs says on this machine: mesh-wide, with the node's layer over it.
|
||||
Declared string `json:"declared,omitempty"`
|
||||
// Profile is what the profile saved in its prefs.js.
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Effective string `json:"effective"`
|
||||
From string `json:"effective_from"`
|
||||
// Drift says how the profile's own value beats the module's without a node layer saying so.
|
||||
Drift string `json:"drift,omitempty"`
|
||||
// Remedy is how drift that clearing prefs.js cannot fix is fixed (the theme).
|
||||
Remedy string `json:"remedy,omitempty"`
|
||||
// module is the value the module means: its default, or Betterbird's.
|
||||
module string
|
||||
}
|
||||
|
||||
// ConfigAnswer is what betterbird_config answers.
|
||||
type ConfigAnswer struct {
|
||||
Profile string `json:"profile"`
|
||||
Defaults string `json:"module_defaults"`
|
||||
Prefs []ConfigPref `json:"prefs"`
|
||||
// Drifting are the names that drift.
|
||||
Drifting []string `json:"drifting"`
|
||||
// Refused are names the module's defaults list that the tools never manage: an account's or a credential's.
|
||||
Refused []string `json:"refused,omitempty"`
|
||||
// PrefsProblem says why the setting prefs on this machine is refused.
|
||||
PrefsProblem string `json:"prefs_problem,omitempty"`
|
||||
// Proposed is the controller's answer to a node layer proposed with keep.
|
||||
Proposed string `json:"proposed,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// config reads every managed preference through the layers: the policy's locks, the module's defaults, the
|
||||
// node layer (the declared user.js), the profile's prefs.js and user.js.
|
||||
func (m *Machine) config() (ConfigAnswer, Profile, error) {
|
||||
a := ConfigAnswer{Prefs: []ConfigPref{}, Drifting: []string{}}
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
return a, p, err
|
||||
}
|
||||
a.Profile = p.Dir
|
||||
sh, serr := m.shippedDefaults()
|
||||
if serr != nil {
|
||||
a.Defaults = "not placed: " + meshDefaults + " is missing; the module's push places it"
|
||||
} else {
|
||||
a.Defaults = fmt.Sprintf("placed: %s, %d preference(s) with a value and %d kept at Betterbird's default",
|
||||
meshDefaults, len(sh.values), len(sh.keeps))
|
||||
}
|
||||
locked := m.locks()
|
||||
own, _ := m.defaults(false)
|
||||
node, _, nerr := m.readDeclaredPrefs()
|
||||
if nerr != nil {
|
||||
a.PrefsProblem = nerr.Error()
|
||||
}
|
||||
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||
saved := readPrefs(prefsRaw)
|
||||
userRaw, _ := readBounded(filepath.Join(p.path, "user.js"))
|
||||
placed := statementsOf(userRaw)
|
||||
|
||||
var names []string
|
||||
for n := range locked {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
listed := map[string]bool{}
|
||||
for _, n := range names {
|
||||
listed[n] = true
|
||||
}
|
||||
for _, n := range append(append([]string{}, sh.order...), sortedKeys(node)...) {
|
||||
if !listed[n] {
|
||||
listed[n] = true
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
theme := activeTheme(p.path)
|
||||
for _, n := range names {
|
||||
if why := unmanageable(n); why != "" {
|
||||
a.Refused = append(a.Refused, n+": "+why)
|
||||
continue
|
||||
}
|
||||
c := ConfigPref{Name: n, Declared: node[n], Profile: saved[n]}
|
||||
if n == themePref && theme != "" {
|
||||
// The add-on database says which theme is active; the preference only mirrors it.
|
||||
c.Profile = quoted(theme)
|
||||
}
|
||||
bb, hasBB := own[n]
|
||||
if hasBB {
|
||||
c.BetterbirdDefault = bb.Value
|
||||
} else if v, ok := builtIn[n]; ok {
|
||||
c.BetterbirdDefault = v
|
||||
bb.From = "Betterbird's built-in default"
|
||||
}
|
||||
switch v, isLocked := locked[n]; {
|
||||
case isLocked:
|
||||
c.Layer, c.Locked, c.module = "locked", v, v
|
||||
case c.Declared != "":
|
||||
c.Layer, c.module = "declared", c.Declared
|
||||
case sh.keeps[n]:
|
||||
c.Layer = "Betterbird's default"
|
||||
c.module = c.BetterbirdDefault
|
||||
default:
|
||||
c.Layer, c.ModuleDefault, c.module = "default", sh.values[n], sh.values[n]
|
||||
}
|
||||
switch {
|
||||
case c.Locked != "":
|
||||
c.Effective, c.From = c.Locked, "locked by the module's policy"
|
||||
case placed[n] != "":
|
||||
c.Effective, c.From = placed[n], "the profile's user.js (wins at the start)"
|
||||
case c.Profile != "":
|
||||
c.Effective, c.From = c.Profile, "the profile's prefs.js"
|
||||
case c.ModuleDefault != "":
|
||||
c.Effective, c.From = c.ModuleDefault, "the module's default"
|
||||
case c.BetterbirdDefault != "":
|
||||
c.Effective, c.From = c.BetterbirdDefault, bb.From
|
||||
default:
|
||||
c.From = "Betterbird's built-in default (in no defaults file)"
|
||||
}
|
||||
if why := drift(c, placed[n]); why != "" {
|
||||
c.Drift = why
|
||||
switch {
|
||||
case n == themePref:
|
||||
c.Remedy = themeRemedy
|
||||
case c.Layer == "declared":
|
||||
c.Remedy = "betterbird_user_js with apply places the setting in the profile's user.js; Betterbird reads it " +
|
||||
"at its next start (betterbird_restart)"
|
||||
}
|
||||
a.Drifting = append(a.Drifting, n)
|
||||
}
|
||||
c.Declared, c.Profile, c.Effective = m.answer(n, c.Declared), m.answer(n, c.Profile), m.answer(n, c.Effective)
|
||||
a.Prefs = append(a.Prefs, c)
|
||||
}
|
||||
return a, p, nil
|
||||
}
|
||||
|
||||
// drift says how the profile differs from the module's configuration, or "": for a declared preference, the
|
||||
// profile's user.js not holding the declared value (so Betterbird does not start with it); for a preference kept at
|
||||
// Betterbird's default, a value in prefs.js that is not it. A lock holds whatever the profile says.
|
||||
func drift(c ConfigPref, placed string) string {
|
||||
if c.Locked != "" {
|
||||
return ""
|
||||
}
|
||||
if c.Declared != "" {
|
||||
if placed == c.Declared {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("the setting prefs says %s, and the profile's user.js does not hold it yet", c.Declared)
|
||||
}
|
||||
if c.Profile == "" || c.Profile == c.module {
|
||||
return ""
|
||||
}
|
||||
want := c.module
|
||||
if want == "" {
|
||||
want = "Betterbird's built-in default"
|
||||
}
|
||||
where := "the profile's prefs.js holds"
|
||||
if c.Name == themePref {
|
||||
where = "the profile's active theme is"
|
||||
}
|
||||
return fmt.Sprintf("%s %s, which beats the module's %s, and the setting prefs does not say so", where, c.Profile, want)
|
||||
}
|
||||
|
||||
// Config is what betterbird_config answers; with keep it proposes a node layer of the setting prefs that holds
|
||||
// the profile's own values of the named managed preferences on this machine (novox/hq issue 345). The module
|
||||
// never writes user.js for it: the operator approves the layer, and the next push writes it.
|
||||
func (m *Machine) Config(keep []string) (ConfigAnswer, error) {
|
||||
a, p, err := m.config()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Note = "drift is a declared value the profile's user.js does not hold yet (betterbird_user_js with apply), or a " +
|
||||
"value the profile saved where the module keeps Betterbird's default: betterbird_config_apply clears that one " +
|
||||
"(Betterbird quit), or keep proposes a node layer of the setting prefs that holds it for this machine"
|
||||
if len(keep) == 0 {
|
||||
return a, nil
|
||||
}
|
||||
by := map[string]ConfigPref{}
|
||||
for _, c := range a.Prefs {
|
||||
by[c.Name] = c
|
||||
}
|
||||
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||
saved := readPrefs(prefsRaw)
|
||||
kept := map[string]string{}
|
||||
for _, n := range keep {
|
||||
c, managed := by[n]
|
||||
switch {
|
||||
case !managed:
|
||||
return a, fmt.Errorf("nothing was proposed: %s is not a preference the module manages (betterbird_config lists them)", n)
|
||||
case c.Locked != "":
|
||||
return a, fmt.Errorf("nothing was proposed: %s is locked by the module's policy; a lock changes only in the catalogue", n)
|
||||
case n == themePref:
|
||||
return a, fmt.Errorf("nothing was proposed: a node layer cannot choose the theme; the add-on manager does (%s)", themeRemedy)
|
||||
case saved[n] == "":
|
||||
return a, fmt.Errorf("nothing was proposed: the profile saved no value of its own for %s", n)
|
||||
}
|
||||
kept[n] = saved[n]
|
||||
}
|
||||
node := nodeName()
|
||||
if node == "" {
|
||||
return a, errors.New("nothing was proposed: this runtime does not say which machine it serves (MESH_NODE)")
|
||||
}
|
||||
// A layer is replaced whole, so the proposal carries this node's layer as it stands, with the kept values in
|
||||
// the setting prefs beside what it held (novox/hq issue 586: one entry per preference).
|
||||
layer, err := readLayer(node)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("nothing was proposed: %v", err)
|
||||
}
|
||||
prefs, _ := layer["prefs"].(map[string]any)
|
||||
if prefs == nil {
|
||||
prefs = map[string]any{}
|
||||
}
|
||||
for _, n := range keep {
|
||||
v, ok := prefValue(kept[n])
|
||||
if !ok {
|
||||
return a, fmt.Errorf("nothing was proposed: the profile's value of %s is not one the setting can hold", n)
|
||||
}
|
||||
prefs[n] = v
|
||||
}
|
||||
layer["prefs"] = prefs
|
||||
said, err := proposeLayer(node, layer, false)
|
||||
a.Proposed = m.maskValue(said)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Note = "proposed for " + node + ": the operator approves it on a channel that proves who answers; the next " +
|
||||
"push writes it, and it is placed at Betterbird's next start from the launcher (or betterbird_restart). " +
|
||||
"The layer carries this node's layer as it stands, with the kept preferences in the setting prefs"
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Cleared is one value betterbird_config_apply took out of prefs.js.
|
||||
type Cleared struct {
|
||||
Name string `json:"name"`
|
||||
Was string `json:"was"`
|
||||
// Now is the value that holds once Betterbird starts again.
|
||||
Now string `json:"now"`
|
||||
}
|
||||
|
||||
// ConfigApplyAnswer is what betterbird_config_apply answers.
|
||||
type ConfigApplyAnswer struct {
|
||||
Profile string `json:"profile"`
|
||||
Applied bool `json:"applied"`
|
||||
Clears []Cleared `json:"clears"`
|
||||
// NotCleared is drift that clearing prefs.js cannot fix, each with how it is fixed.
|
||||
NotCleared []string `json:"not_cleared,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// ConfigApply takes the drifting values out of the profile's prefs.js, so the module's value holds at the next
|
||||
// start. Without apply it lists them. It acts only while Betterbird is not running, because Betterbird
|
||||
// rewrites prefs.js when it quits. Every other line of prefs.js is kept as it was.
|
||||
func (m *Machine) ConfigApply(apply bool) (ConfigApplyAnswer, error) {
|
||||
c, p, err := m.config()
|
||||
a := ConfigApplyAnswer{Profile: c.Profile, Clears: []Cleared{}}
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if strings.HasPrefix(c.Defaults, "not placed") {
|
||||
return a, fmt.Errorf("the module's defaults are %s: nothing to bring the profile back to", c.Defaults)
|
||||
}
|
||||
drifting := map[string]bool{}
|
||||
for _, cp := range c.Prefs {
|
||||
if cp.Drift != "" && cp.Remedy != "" {
|
||||
a.NotCleared = append(a.NotCleared, cp.Name+": "+cp.Remedy)
|
||||
continue
|
||||
}
|
||||
if cp.Drift != "" {
|
||||
drifting[cp.Name] = true
|
||||
now := m.answer(cp.Name, cp.module)
|
||||
if now == "" {
|
||||
now = "Betterbird's built-in default"
|
||||
}
|
||||
a.Clears = append(a.Clears, Cleared{Name: cp.Name, Was: cp.Profile, Now: now})
|
||||
}
|
||||
}
|
||||
if !apply {
|
||||
a.Note = "a dry run: these values would be taken out of prefs.js. Quit Betterbird, then call with apply"
|
||||
return a, nil
|
||||
}
|
||||
if ps := m.mainProcs(); len(ps) > 0 {
|
||||
return a, fmt.Errorf("Betterbird is running (pid %d): it rewrites prefs.js when it quits, so nothing was cleared; quit it, then apply again", ps[0].PID)
|
||||
}
|
||||
// The profile's own lock is Gecko's sign that a Betterbird holds it, whichever installation started it.
|
||||
if _, err := os.Lstat(filepath.Join(p.path, "lock")); err == nil {
|
||||
return a, errors.New("the profile is locked (its lock file is there): a Betterbird holds it, or one ended " +
|
||||
"without quitting; nothing was cleared. Quit Betterbird (or start and quit it once), then apply again")
|
||||
}
|
||||
if len(drifting) == 0 {
|
||||
a.Applied, a.Note = true, "nothing in prefs.js drifts: it was left as it was"
|
||||
return a, nil
|
||||
}
|
||||
target := filepath.Join(p.path, "prefs.js")
|
||||
raw, err := readBounded(target)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
st, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
lines := strings.SplitAfter(string(raw), "\n")
|
||||
var kept strings.Builder
|
||||
for _, l := range lines {
|
||||
if mm := prefLine.FindStringSubmatch(strings.TrimRight(l, "\n")); mm != nil && drifting[mm[1]] {
|
||||
continue
|
||||
}
|
||||
kept.WriteString(l)
|
||||
}
|
||||
tmp := target + ".mesh-new"
|
||||
if err := writeSynced(tmp, []byte(kept.String()), st.Mode().Perm()); err != nil {
|
||||
os.Remove(tmp)
|
||||
return a, err
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
os.Remove(tmp)
|
||||
return a, err
|
||||
}
|
||||
a.Applied = true
|
||||
a.Note = fmt.Sprintf("cleared %d value(s) from prefs.js; the module's values hold from Betterbird's next start", len(a.Clears))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// writeSynced writes a file and syncs it to disk before it is renamed into place, so a crash never leaves an
|
||||
// empty prefs.js.
|
||||
func writeSynced(path string, data []byte, mode os.FileMode) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The configuration the module ships (novox/hq issue 345): the policy's locks, the module's defaults file, and the
|
||||
// tools that show the layers, flag drift, clear it and propose a node layer.
|
||||
|
||||
// shippedDefaultsWant is the setup the operator approved on 2026-10-12 (the laptop's, with reminders missed while
|
||||
// asleep shown at the next start and chat off), which the module shipped as its defaults file until issue 586 moved
|
||||
// it into the mesh-wide layer of the setting prefs: the catalogue holds none of it now.
|
||||
var shippedDefaultsWant = map[string]string{
|
||||
"mail.uidensity": "0",
|
||||
"mail.uifontsize": "14",
|
||||
"mail.dark-reader.enabled": "false",
|
||||
"mail.pane_config.dynamic": "2",
|
||||
"mail.pane_config.multiline_all": "true",
|
||||
"mail.threadpane.listview": "0",
|
||||
"mailnews.default_view_flags": "1",
|
||||
"mail.warn_on_shift_delete": "false",
|
||||
"mail.biff.show_alert": "false",
|
||||
"mail.biff.play_sound": "false",
|
||||
"mail.biff.show_tray_icon": "false",
|
||||
"mail.biff.use_system_alert": "true",
|
||||
"calendar.view.daystarthour": "9",
|
||||
"calendar.view.dayendhour": "20",
|
||||
"calendar.item.promptDelete": "false",
|
||||
"calendar.timezone.useSystemTimezone": "true",
|
||||
"calendar.alarms.show": "true",
|
||||
"calendar.alarms.showmissed": "true",
|
||||
"mail.chat.enabled": "false",
|
||||
"mailnews.message_display.disable_remote_image": "true",
|
||||
"privacy.donottrackheader.enabled": "true",
|
||||
}
|
||||
|
||||
var shippedKeepsWant = []string{"extensions.activeThemeID", "browser.display.use_system_colors",
|
||||
"layout.css.always_underline_links", "mail.spam.manualMark", "mail.compose.show_attachment_pane"}
|
||||
|
||||
func repoFile(t *testing.T, rel string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// withDefaults places the module's defaults file as the push does.
|
||||
func (f *fake) withDefaults() {
|
||||
f.t.Helper()
|
||||
f.write(meshDefaults, string(repoFile(f.t, "install/defaults-pref/mesh-defaults.js")))
|
||||
}
|
||||
|
||||
// omni places a packed application holding the given files.
|
||||
func (f *fake) omni(files map[string]string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.Root, omniJa)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
out, err := os.Create(full)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(out)
|
||||
for name, body := range files {
|
||||
e, _ := w.Create(name)
|
||||
e.Write([]byte(body))
|
||||
}
|
||||
w.Close()
|
||||
out.Close()
|
||||
}
|
||||
|
||||
// betterbirdsOwn is a small omni.ja: two files that contradict each other, as all-thunderbird.js and
|
||||
// mailnews.js do in the pinned release.
|
||||
func (f *fake) betterbirdsOwn() {
|
||||
f.omni(map[string]string{
|
||||
"greprefs.js": `pref("gfx.webrender.software", false);` + "\n",
|
||||
"defaults/pref/all-thunderbird.js": `pref("offline.autoDetect", true);` + "\n" +
|
||||
`pref("mail.threadpane.listview", 1);` + "\n" + `pref("mail.uidensity", 1);` + "\n" +
|
||||
`pref("mail.shell.checkDefaultClient", true);` + "\n",
|
||||
"defaults/pref/mailnews.js": `pref("offline.autoDetect", false);` + "\n" +
|
||||
`pref("mail.biff.show_alert", true); // a comment after it` + "\n" + `pref("mail.spam.manualMark", false);` + "\n" +
|
||||
`pref("mailnews.start_page.enabled", true);` + "\n",
|
||||
"defaults/pref/calendar.js": `pref("calendar.alarms.showmissed", false);` + "\n",
|
||||
"chrome/other.js": `pref("not.a.default", 1);` + "\n",
|
||||
})
|
||||
}
|
||||
|
||||
func stopBetterbird(t *testing.T, f *fake) {
|
||||
t.Helper()
|
||||
for _, pid := range []string{"4000", "4001"} {
|
||||
if err := os.RemoveAll(filepath.Join(f.Root, "proc", pid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if a := artifact(t, m, "defaults"); a["from"] != "install/defaults-pref" {
|
||||
t.Fatalf("the defaults file ships through the defaults artifact: %v", a)
|
||||
}
|
||||
if r := resource(t, m, "spelling"); r["path"] != prefDir || r["artifact"] != "defaults" {
|
||||
t.Fatalf("placed in Betterbird's own defaults directory: %v", r)
|
||||
}
|
||||
raw := repoFile(t, "install/distribution/policies.json")
|
||||
if why := policyProblem(raw); why != "" {
|
||||
t.Fatal(why)
|
||||
}
|
||||
var p struct {
|
||||
Policies map[string]json.RawMessage `json:"policies"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, want := range map[string]string{"DisableAppUpdate": "true", "DisableTelemetry": "true", "ExtensionUpdate": "false"} {
|
||||
if string(p.Policies[k]) != want {
|
||||
t.Errorf("%s is %s, not %s", k, p.Policies[k], want)
|
||||
}
|
||||
}
|
||||
prefs := policyPrefsOf(raw)
|
||||
for _, n := range []string{"mail.shell.checkDefaultClient", "mail.inappnotifications.enabled", "mailnews.start_page.enabled"} {
|
||||
if prefs[n] != (policyPref{Value: "false", Locked: true}) {
|
||||
t.Errorf("%s is locked off: %+v", n, prefs[n])
|
||||
}
|
||||
}
|
||||
// Betterbird's Preferences policy accepts only these prefixes (Policies.sys.mjs); privacy. and toolkit. are not.
|
||||
allowed := []string{"accessibility.", "app.update.", "browser.", "calendar.", "chat.", "datareporting.policy.", "dom.",
|
||||
"extensions.", "general.autoScroll", "general.smoothScroll", "geo.", "gfx.", "intl.", "layers.", "layout.", "mail.",
|
||||
"mailnews.", "media.", "network.", "pdfjs.", "places.", "print.", "signon.", "spellchecker.", "ui.", "widget."}
|
||||
for n := range prefs {
|
||||
ok := false
|
||||
for _, a := range allowed {
|
||||
ok = ok || strings.HasPrefix(n, a)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("the policy cannot lock %s: its prefix is not one Betterbird accepts", n)
|
||||
}
|
||||
}
|
||||
|
||||
text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))
|
||||
sh := parseShipped([]byte(text))
|
||||
if len(sh.values) != 0 {
|
||||
t.Errorf("the module's defaults hold no value of the operator's: they are the setting prefs (issue 586): %v", sh.values)
|
||||
}
|
||||
for n := range shippedDefaultsWant {
|
||||
if strings.Contains(text, n) {
|
||||
t.Errorf("%s is the operator's choice and lives in the setting prefs, never in the catalogue", n)
|
||||
}
|
||||
}
|
||||
var keeps []string
|
||||
for _, n := range sh.order {
|
||||
if sh.keeps[n] {
|
||||
keeps = append(keeps, n)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(keeps, shippedKeepsWant) {
|
||||
t.Errorf("the preferences kept at Betterbird's default: %v", keeps)
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, l := range lines {
|
||||
if defaultLine.MatchString(l) && (i == 0 || !strings.HasPrefix(lines[i-1], "// ")) {
|
||||
t.Errorf("line %d has no comment saying why: %s", i+1, l)
|
||||
}
|
||||
if t2 := strings.TrimSpace(l); t2 != "" && !strings.HasPrefix(t2, "//") && !defaultLine.MatchString(l) {
|
||||
t.Errorf("line %d is neither a comment nor a pref(…) line: %s", i+1, l)
|
||||
}
|
||||
}
|
||||
for n := range sh.values {
|
||||
if withheld(n) != "" || passwordSecret.MatchString(n) {
|
||||
t.Errorf("%s is an account's or a credential's preference, never a default of the module", n)
|
||||
}
|
||||
if _, locked := prefs[n]; locked {
|
||||
t.Errorf("%s is both locked and a default", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2: Betterbird's own defaults are values read from omni.ja, in Gecko's order, and the module's beside them.
|
||||
func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
f.withDefaults()
|
||||
f.betterbirdsOwn()
|
||||
links(t, nil)
|
||||
got, err := f.Prefs([]string{"offline.autoDetect", "mail.threadpane.listview", "mail.uidensity", "gfx.webrender.software",
|
||||
"mailnews.start_page.enabled", "layout.css.always_underline_links", "not.a.default"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]Pref{}
|
||||
for _, p := range got["prefs"].([]Pref) {
|
||||
by[p.Name] = p
|
||||
}
|
||||
if p := by["offline.autoDetect"]; p.Value != "true" || p.Default != "true" || !strings.Contains(p.DefaultFrom, "all-thunderbird.js") {
|
||||
t.Errorf("of two files, the one first by name holds, as Gecko reads them: %+v", p)
|
||||
}
|
||||
if p := by["mail.threadpane.listview"]; p.Value != "1" || strings.Contains(p.From, "module") || strings.Contains(p.DefaultFrom, "module") {
|
||||
t.Errorf("the module ships no value of the operator's, so Betterbird's own default holds: %+v", p)
|
||||
}
|
||||
if p := by["gfx.webrender.software"]; p.Value != "false" || !strings.Contains(p.From, "greprefs.js") {
|
||||
t.Errorf("%+v", p)
|
||||
}
|
||||
if p := by["mailnews.start_page.enabled"]; p.Value != "false" || p.From != "locked by the module's policy" || p.Default != "true" {
|
||||
t.Errorf("a locked preference says its lock and Betterbird's default under it: %+v", p)
|
||||
}
|
||||
if p := by["layout.css.always_underline_links"]; p.Value != "" || !strings.Contains(p.From, "built-in") {
|
||||
t.Errorf("a preference in no file is said as such: %+v", p)
|
||||
}
|
||||
if p := by["not.a.default"]; p.Default != "" {
|
||||
t.Errorf("only defaults/pref and greprefs.js are defaults: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// T4: of mailnews.* only what names an account, a server or the operator's tags is withheld.
|
||||
func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) {
|
||||
for name, held := range map[string]bool{
|
||||
"mailnews.start_page.enabled": false,
|
||||
"mailnews.default_view_flags": false,
|
||||
"mailnews.message_display.disable_remote_image": false,
|
||||
"mailnews.tags.$label1.tag": true,
|
||||
"mailnews.smtp.loglevel": true,
|
||||
"mailnews.reply_to_self_check_all_ident": true,
|
||||
"mailnews.auto_config.fetchFromISP.sendEmailAddress": true,
|
||||
"mailnews.oauth.useSchemeRedirect": true,
|
||||
"mail.server.server1.hostname": true,
|
||||
} {
|
||||
if got := withheld(name) != ""; got != held {
|
||||
t.Errorf("%s: withheld %v, want %v", name, got, held)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T1: every managed preference through the layers, and drift: a declared value the profile's user.js does not hold
|
||||
// yet, or a value the profile saved where the module keeps Betterbird's default.
|
||||
func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
f.withDefaults()
|
||||
f.betterbirdsOwn()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", false); user_pref("calendar.alarms.showmissed", true);`)
|
||||
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n")
|
||||
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
|
||||
`user_pref("mail.uifontsize", 12);`+"\n"+
|
||||
`user_pref("mail.biff.show_alert", true);`+"\n"+
|
||||
`user_pref("mail.spam.manualMark", true);`+"\n"+
|
||||
`user_pref("browser.display.use_system_colors", false);`+"\n"+
|
||||
`user_pref("mailnews.start_page.enabled", true);`+"\n")
|
||||
c, err := f.Config(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]ConfigPref{}
|
||||
for _, p := range c.Prefs {
|
||||
by[p.Name] = p
|
||||
}
|
||||
if len(c.Prefs) != 3+3+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") {
|
||||
t.Fatalf("every lock, declared preference and kept default: %d, %s", len(c.Prefs), c.Defaults)
|
||||
}
|
||||
if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.spam.manualMark", "mail.biff.show_alert"}) {
|
||||
t.Fatalf("drift: %v", c.Drifting)
|
||||
}
|
||||
if p := by["mail.biff.show_alert"]; p.Layer != "declared" || p.Declared != "false" || p.Profile != "true" ||
|
||||
p.Effective != "true" || !strings.Contains(p.Drift, "the setting prefs says false") || !strings.Contains(p.Remedy, "betterbird_user_js") {
|
||||
t.Errorf("a declared value the profile's user.js does not hold yet: %+v", p)
|
||||
}
|
||||
if p := by["mail.spam.manualMark"]; p.Layer != "Betterbird's default" || p.BetterbirdDefault != "false" ||
|
||||
!strings.Contains(p.Drift, "beats the module's false") || p.Remedy != "" {
|
||||
t.Errorf("a saved value where the module keeps Betterbird's default: %+v", p)
|
||||
}
|
||||
if p := by["extensions.activeThemeID"]; p.Layer != "Betterbird's default" || !strings.Contains(p.Drift, `"default-theme@mozilla.org"`) ||
|
||||
!strings.Contains(p.Remedy, "add-on manager") || p.Profile != `"thunderbird-compact-dark@mozilla.org"` {
|
||||
t.Errorf("another theme is drift whose remedy is the add-on manager, its id answered whole: %+v", p)
|
||||
}
|
||||
if p := by["browser.display.use_system_colors"]; p.Drift != "" {
|
||||
t.Errorf("a saved value equal to Betterbird's default is not drift: %+v", p)
|
||||
}
|
||||
if p := by["mail.uifontsize"]; p.Drift != "" || p.Declared != "16" || p.Effective != "16" || !strings.Contains(p.From, "user.js") {
|
||||
t.Errorf("a declared value the user.js holds wins at the start, whatever prefs.js saved: %+v", p)
|
||||
}
|
||||
if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" {
|
||||
t.Errorf("a lock wins over the profile: %+v", p)
|
||||
}
|
||||
if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.Layer != "declared" {
|
||||
t.Errorf("%+v", p)
|
||||
}
|
||||
// T5: the check says the drift, with both remedies.
|
||||
ch, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, x := range ch.Findings {
|
||||
found = found || (strings.Contains(x.What, "1 managed preference(s) drift") && strings.Contains(x.What, "mail.spam.manualMark") &&
|
||||
strings.Contains(x.Do, "betterbird_config_apply") && strings.Contains(x.Do, "keep"))
|
||||
}
|
||||
theme := false
|
||||
for _, x := range ch.Findings {
|
||||
theme = theme || (strings.HasPrefix(x.What, "extensions.activeThemeID drifts") && strings.Contains(x.Do, "add-on manager") &&
|
||||
!strings.Contains(x.Do, "betterbird_config_apply"))
|
||||
}
|
||||
declared := false
|
||||
for _, x := range ch.Findings {
|
||||
declared = declared || (strings.HasPrefix(x.What, "mail.biff.show_alert drifts") && strings.Contains(x.Do, "betterbird_user_js"))
|
||||
}
|
||||
if !found || !theme || !declared {
|
||||
t.Fatalf("the check reports the drift: %+v", ch.Findings)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, meshDefaults))
|
||||
ch, _ = f.Check()
|
||||
found = false
|
||||
for _, x := range ch.Findings {
|
||||
found = found || strings.Contains(x.What, "the module's defaults are not placed")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("defaults missing on a node of the mesh's release is a finding: %+v", ch.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
// T3: the drifting values are cleared from prefs.js only while Betterbird is not running, and nothing else.
|
||||
func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
f.withDefaults()
|
||||
f.betterbirdsOwn()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
prefs := "// Mozilla User Preferences\n" +
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" +
|
||||
`user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
|
||||
`user_pref("mail.spam.manualMark", true);` + "\n" +
|
||||
`user_pref("mail.uidensity", 0);` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
dry, err := f.ConfigApply(false)
|
||||
if err != nil || dry.Applied || len(dry.Clears) != 1 || len(dry.NotCleared) != 1 {
|
||||
t.Fatalf("a dry run lists and changes nothing: %+v %v", dry, err)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != prefs {
|
||||
t.Fatal("a dry run wrote prefs.js")
|
||||
}
|
||||
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "running") {
|
||||
t.Fatalf("refused while Betterbird runs: %v", err)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != prefs {
|
||||
t.Fatal("prefs.js changed while Betterbird runs")
|
||||
}
|
||||
stopBetterbird(t, f)
|
||||
f.write(profileDir+"/lock", "")
|
||||
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "locked") {
|
||||
t.Fatalf("refused while the profile's own lock is there: %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(f.Root, profileDir, "lock")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := f.ConfigApply(true)
|
||||
if err != nil || !a.Applied {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
want := "// Mozilla User Preferences\n" +
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" +
|
||||
`user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
|
||||
`user_pref("mail.uidensity", 0);` + "\n"
|
||||
if got := f.read(profileDir + "/prefs.js"); got != want {
|
||||
t.Fatalf("only the drifting lines go:\n%s", got)
|
||||
}
|
||||
said := map[string]Cleared{}
|
||||
for _, c := range a.Clears {
|
||||
said[c.Name] = c
|
||||
}
|
||||
if c := said["mail.spam.manualMark"]; c.Was != "true" || c.Now != "false" {
|
||||
t.Errorf("it says what it cleared and what holds now: %+v", c)
|
||||
}
|
||||
if _, cleared := said["extensions.activeThemeID"]; cleared || len(a.NotCleared) != 1 || !strings.Contains(a.NotCleared[0], "add-on manager") {
|
||||
t.Errorf("the theme is never cleared, and said with its remedy: %+v", a)
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
if strings.Contains(string(raw), "example.org") {
|
||||
t.Fatalf("no account value leaves: %s", raw)
|
||||
}
|
||||
if c, _ := f.Config(nil); !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) {
|
||||
t.Fatalf("once applied, only the theme drifts, until the add-on manager changes it: %v", c.Drifting)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, meshDefaults))
|
||||
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "not placed") {
|
||||
t.Fatalf("without the module's defaults there is nothing to come back to: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// T6: keeping a value on one node is that node's layer of the setting prefs proposed through the controller, one
|
||||
// entry per preference beside what the layer holds (issue 586), never a write to user.js.
|
||||
func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
f.withDefaults()
|
||||
f.betterbirdsOwn()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 12);`)
|
||||
f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n")
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "shanks" }
|
||||
var asked []map[string]any
|
||||
asker = func(v string, a any) (json.RawMessage, error) {
|
||||
args := a.(map[string]any)
|
||||
asked = append(asked, args)
|
||||
if args["propose"] == nil {
|
||||
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on shanks, every value"}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
|
||||
}
|
||||
before := f.read(testHome + "/" + prefsFile)
|
||||
c, err := f.Config([]string{"mail.uifontsize", "mail.spam.manualMark"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" ||
|
||||
asked[1]["module"] != "betterbird" {
|
||||
t.Fatalf("the node's layer read, then proposed: %v", asked)
|
||||
}
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{"calendar.alarms.playsound": false, "mail.uifontsize": float64(16), "mail.spam.manualMark": true}
|
||||
if !reflect.DeepEqual(values["prefs"], want) || values["identities"] == nil {
|
||||
t.Fatalf("the layer as it stands with the kept values, one entry each:\n got %v\nwant %v", values, want)
|
||||
}
|
||||
if c.Proposed != "proposed: p-1" || f.read(testHome+"/"+prefsFile) != before {
|
||||
t.Fatalf("proposed, and nothing written: %+v", c)
|
||||
}
|
||||
for _, bad := range [][]string{{"mailnews.start_page.enabled"}, {"some.other.pref"}, {"calendar.alarms.playsound"}} {
|
||||
asked = nil
|
||||
if _, err := f.Config(bad); err == nil || len(asked) != 0 {
|
||||
t.Errorf("%v: a lock, an unmanaged name or a value the profile did not save is refused: %v", bad, err)
|
||||
}
|
||||
}
|
||||
nodeName = func() string { return "" }
|
||||
if _, err := f.Config([]string{"mail.uifontsize"}); err == nil || !strings.Contains(err.Error(), "MESH_NODE") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of pull request 253: whether Betterbird asks to be the default client is decided from the policy's
|
||||
// own Preferences entry, never from what else the policy lacks.
|
||||
func TestTheDefaultClientAskIsDecidedFromThePreferencesEntry(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
f.write(policyFile, `{"policies": {"ExtensionSettings": {}}}`)
|
||||
if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why == "" {
|
||||
t.Fatal("a policy lacking the updater and the lock still asks")
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "locked"}}}}`)
|
||||
if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why != "" {
|
||||
t.Fatalf("the lock holds whatever else the policy lacks: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of pull request 256: the theme is the add-on database's, and a saved default is not drift.
|
||||
func TestTheThemeIsTheAddOnDatabasesAndASavedDefaultIsNotDrift(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
f.withDefaults()
|
||||
links(t, nil)
|
||||
f.write(profileDir+"/prefs.js", `user_pref("browser.display.use_system_colors", false);`+"\n"+
|
||||
`user_pref("layout.css.always_underline_links", false);`+"\n")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"thunderbird-compact-dark@mozilla.org","type":"theme","active":true,"location":"app-builtin"},`+
|
||||
`{"id":"default-theme@mozilla.org","type":"theme","active":false,"location":"app-builtin"}]}`)
|
||||
c, err := f.Config(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) {
|
||||
t.Fatalf("the active theme from the add-on database, with no preference saved, is drift; a saved default is not: %v", c.Drifting)
|
||||
}
|
||||
f.write(profileDir+"/prefs.js", `user_pref("extensions.activeThemeID", "default-theme@mozilla.org");`+"\n")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"default-theme@mozilla.org","type":"theme","active":true,"location":"app-builtin"}]}`)
|
||||
if c, _ := f.Config(nil); len(c.Drifting) != 0 {
|
||||
t.Fatalf("a profile back on the default theme does not drift: %v", c.Drifting)
|
||||
}
|
||||
if _, err := f.Config([]string{"extensions.activeThemeID"}); err == nil || !strings.Contains(err.Error(), "add-on manager") {
|
||||
t.Fatalf("a node layer cannot choose the theme: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of pull request 256: a credential's or an account's name in the defaults file is never managed.
|
||||
func TestAnAccountsNameInTheDefaultsIsNeverManaged(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
f.write(meshDefaults, string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))+
|
||||
`pref("mail.identity.id1.useremail", "x");`+"\n"+"// keeps Betterbird's default: mail.server.server1.hostname\n"+
|
||||
`pref("mail.smtpservers", "");`+"\n")
|
||||
prefs := `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
|
||||
`user_pref("mail.server.server1.hostname", "imap.example.org");` + "\n" +
|
||||
`user_pref("mail.smtpservers", "smtp1");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
c, err := f.Config(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Refused) != 3 || len(c.Drifting) != 0 {
|
||||
t.Fatalf("refused, never managed: %+v", c)
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
if strings.Contains(string(raw), "example.org") {
|
||||
t.Fatalf("no account value is answered: %s", raw)
|
||||
}
|
||||
stopBetterbird(t, f)
|
||||
if _, err := f.ConfigApply(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != prefs {
|
||||
t.Fatal("prefs.js keeps every account's value")
|
||||
}
|
||||
ch, _ := f.Check()
|
||||
found := false
|
||||
for _, x := range ch.Findings {
|
||||
found = found || (strings.Contains(x.What, "3 preference(s) the tools never manage") && strings.Contains(x.Do, "mesh-defaults.js"))
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the check says so: %+v", ch.Findings)
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
package main
|
||||
|
||||
// Betterbird's whole configuration, beside the accounts (novox/hq ADR 0329 §4–§6, issue 586).
|
||||
//
|
||||
// Five settings, each mesh-wide with a node's layer over it, reach the machine as two files the node-engine
|
||||
// merges from the module's layers key by key:
|
||||
//
|
||||
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
|
||||
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
|
||||
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
|
||||
// so a change to it is the operator's to approve.
|
||||
//
|
||||
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
||||
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
|
||||
// calendar or an address book its `key`: the profile's key to adopt there (§4, §7). No value holds a password,
|
||||
// a token or a cookie: a key shaped like one is refused whole, and so is an address whose user part carries a
|
||||
// password (§2). A setting holds no line break (novox/hq issue 339), so a signature is a list of its lines.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// configurationFile holds identities, calendars, address-books and filters; tagsFile holds tags.
|
||||
configurationFile = ".config/betterbird/configuration.json"
|
||||
tagsFile = ".config/betterbird/tags.json"
|
||||
)
|
||||
|
||||
// The five kinds, in the order the tools answer them.
|
||||
const (
|
||||
kindIdentities = "identities"
|
||||
kindCalendars = "calendars"
|
||||
kindAddressBooks = "address-books"
|
||||
kindFilters = "filters"
|
||||
kindTags = "tags"
|
||||
)
|
||||
|
||||
var allKinds = []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters, kindTags}
|
||||
|
||||
// Identity is one identity of a declared account. The account's default identity is the entry that gives no
|
||||
// address: its name and address are the ones the setting accounts gives (§4). Any other entry is another
|
||||
// identity of that account, matched by its address.
|
||||
type Identity struct {
|
||||
Account string `json:"account"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ReplyTo string `json:"reply-to,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
// Signature is the signature's text, a line to an element.
|
||||
Signature []string `json:"signature,omitempty"`
|
||||
SignatureHTML bool `json:"signature-html,omitempty"`
|
||||
// SignatureAboveQuote puts the reply and its signature above the quoted message (Betterbird's "start my
|
||||
// reply above the quote", signature below the reply); false is below the quote.
|
||||
SignatureAboveQuote bool `json:"signature-above-quote,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// Calendar is one CalDAV calendar.
|
||||
type Calendar struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Color string `json:"color,omitempty"`
|
||||
ReadOnly bool `json:"read-only,omitempty"`
|
||||
// Reminders absent is on.
|
||||
Reminders *bool `json:"reminders,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
// Invitations is the identities entry whose address answers invitations; absent, none.
|
||||
Invitations string `json:"invitations,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// AddressBook is one CardDAV address book.
|
||||
type AddressBook struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
User string `json:"user,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// Tag is one tag, keyed in the setting by its own key.
|
||||
type Tag struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
// Order is its place among the tags, from 1.
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// Filter is one message filter of an account, in its place in the account's list.
|
||||
type Filter struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// When it runs: new-mail, manual, after-junk, sending, archiving, periodic.
|
||||
When []string `json:"when"`
|
||||
// Match is all (every condition holds), any (one does) or every-message (no condition).
|
||||
Match string `json:"match"`
|
||||
Conditions []Condition `json:"conditions,omitempty"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// Condition is one of a filter's conditions, in Betterbird's own words: a field, an operator, a value.
|
||||
type Condition struct {
|
||||
Field string `json:"field"`
|
||||
Op string `json:"op"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Action is one of a filter's actions, in Betterbird's own words. A folder is named by its path within the
|
||||
// account (INBOX/Receipts), and becomes that folder's address on each node (§8).
|
||||
type Action struct {
|
||||
Action string `json:"action"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Configuration is the five settings as the machine has them.
|
||||
type Configuration struct {
|
||||
Identities map[string]*Identity `json:"identities,omitempty"`
|
||||
Calendars map[string]*Calendar `json:"calendars,omitempty"`
|
||||
AddressBooks map[string]*AddressBook `json:"address-books,omitempty"`
|
||||
Filters map[string][]Filter `json:"filters,omitempty"`
|
||||
Tags map[string]*Tag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
entryName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
|
||||
tagKey = regexp.MustCompile(`^[$A-Za-z0-9_.\-]{1,64}$`)
|
||||
colorWord = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
// secretName is a field's name that says it holds a credential: such a field is withheld from every answer (§2).
|
||||
secretName = regexp.MustCompile(`(?i)(password|passwd|token|secret|cookie)`)
|
||||
// userPassword is an address whose user part carries a password: scheme://user:password@host.
|
||||
userPassword = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://[^/:@\s]*):[^@/\s]+@`)
|
||||
)
|
||||
|
||||
// The words a filter is written in.
|
||||
var (
|
||||
filterWhen = map[string]int{"new-mail": 0x1, "manual": 0x10, "after-junk": 0x20, "sending": 0x40,
|
||||
"archiving": 0x80, "periodic": 0x100}
|
||||
filterFields = map[string]bool{"subject": true, "from": true, "to": true, "cc": true, "to or cc": true,
|
||||
"all addresses": true, "body": true, "date": true, "priority": true, "status": true, "age in days": true,
|
||||
"size": true, "tag": true, "junk status": true, "junk percent": true, "junk score origin": true,
|
||||
"has attachment status": true, "date received": true}
|
||||
filterOps = map[string]bool{"contains": true, "doesn't contain": true, "is": true, "isn't": true,
|
||||
"begins with": true, "ends with": true, "is empty": true, "isn't empty": true, "is before": true,
|
||||
"is after": true, "is greater than": true, "is less than": true, "is higher than": true,
|
||||
"is lower than": true, "is in ab": true, "isn't in ab": true, "matches": true, "doesn't match": true}
|
||||
// filterActions are the actions a declared filter may take, and whether each names a folder or a value.
|
||||
filterActions = map[string]string{"Move to folder": "folder", "Copy to folder": "folder",
|
||||
"Change priority": "value", "Delete": "", "Mark read": "", "Mark unread": "", "Mark flagged": "",
|
||||
"Ignore thread": "", "Ignore subthread": "", "Watch thread": "", "AddTag": "value", "JunkScore": "value",
|
||||
"Forward": "value", "Stop execution": "", "Delete from Pop3 server": "", "Leave on Pop3 server": "",
|
||||
"Fetch body from Pop3Server": ""}
|
||||
)
|
||||
|
||||
// readConfiguration reads the five settings. A missing file holds none of its kinds (the module is older
|
||||
// here); a file that carries a key shaped like a credential, an address with a password, an unknown field or a
|
||||
// value Betterbird cannot be given is refused whole, naming the entry and never a value.
|
||||
func (m *Machine) readConfiguration() (Configuration, error) {
|
||||
var c Configuration
|
||||
var problems []string
|
||||
for _, file := range []string{configurationFile, tagsFile} {
|
||||
raw, err := readBounded(m.home(file))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return Configuration{}, err
|
||||
}
|
||||
if err := decodeInto(raw, file, &c); err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Configuration{}, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
if p := configurationProblems(c); len(p) > 0 {
|
||||
return Configuration{}, fmt.Errorf("the configuration is refused: %s", strings.Join(p, "; "))
|
||||
}
|
||||
c.dropNulls()
|
||||
for _, cal := range c.Calendars {
|
||||
cal.Color = strings.ToLower(cal.Color)
|
||||
}
|
||||
for _, t := range c.Tags {
|
||||
t.Color = strings.ToUpper(t.Color)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// decodeInto reads one settings file into c, refusing a credential-shaped key or an unknown field.
|
||||
func decodeInto(raw []byte, file string, c *Configuration) error {
|
||||
var tree any
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return fmt.Errorf("~/%s is not JSON: %v", file, err)
|
||||
}
|
||||
if where := secretKeyIn(tree, ""); where != "" {
|
||||
return fmt.Errorf("~/%s carries %s, a key shaped like a credential: the configuration is declared without "+
|
||||
"any password, token or cookie (novox/hq ADR 0329 §2); take it out at the controller's terminal", file, where)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var into Configuration
|
||||
if err := dec.Decode(&into); err != nil {
|
||||
return fmt.Errorf("~/%s does not hold the shape of the settings it takes: %v", file, err)
|
||||
}
|
||||
if file == tagsFile {
|
||||
if into.Identities != nil || into.Calendars != nil || into.AddressBooks != nil || into.Filters != nil {
|
||||
return fmt.Errorf("~/%s holds only tags", file)
|
||||
}
|
||||
c.Tags = into.Tags
|
||||
return nil
|
||||
}
|
||||
if into.Tags != nil {
|
||||
return fmt.Errorf("~/%s holds no tags: tags are their own setting", file)
|
||||
}
|
||||
c.Identities, c.Calendars, c.AddressBooks, c.Filters = into.Identities, into.Calendars, into.AddressBooks, into.Filters
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropNulls takes out the entries a node's layer set to null: not on this node.
|
||||
func (c *Configuration) dropNulls() {
|
||||
for k, v := range c.Identities {
|
||||
if v == nil {
|
||||
delete(c.Identities, k)
|
||||
}
|
||||
}
|
||||
for k, v := range c.Calendars {
|
||||
if v == nil {
|
||||
delete(c.Calendars, k)
|
||||
}
|
||||
}
|
||||
for k, v := range c.AddressBooks {
|
||||
if v == nil {
|
||||
delete(c.AddressBooks, k)
|
||||
}
|
||||
}
|
||||
for k, v := range c.Filters {
|
||||
if v == nil {
|
||||
delete(c.Filters, k)
|
||||
}
|
||||
}
|
||||
for k, v := range c.Tags {
|
||||
if v == nil {
|
||||
delete(c.Tags, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addressPassword says whether an address carries a password in its user part.
|
||||
func addressPassword(s string) bool {
|
||||
if userPassword.MatchString(s) {
|
||||
return true
|
||||
}
|
||||
if u, err := url.Parse(s); err == nil && u.User != nil {
|
||||
_, has := u.User.Password()
|
||||
return has
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withoutPassword is an address with the password of its user part as ‹withheld› (§2).
|
||||
func withoutPassword(s string) string {
|
||||
return userPassword.ReplaceAllString(s, "${1}:‹withheld›@")
|
||||
}
|
||||
|
||||
// davURL says what is wrong with a calendar's or an address book's address, or "".
|
||||
func davURL(s string) string {
|
||||
if addressPassword(s) {
|
||||
return "its address carries a password in its user part, which no setting holds (novox/hq ADR 0329 §2): " +
|
||||
"Betterbird asks for the password at the first sign-in"
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return "its address is an https:// (or http://) address of the server"
|
||||
}
|
||||
if strings.ContainsAny(s, lineEnds+`"\`) {
|
||||
return "its address holds a quote, a backslash or a line break"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// configurationProblems is everything wrong with the five settings, each said by its kind and entry name.
|
||||
func configurationProblems(c Configuration) []string {
|
||||
var out []string
|
||||
say := func(kind, name, format string, args ...any) {
|
||||
out = append(out, fmt.Sprintf("%s %s: ", kind, name)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
text := func(kind, name, field, v string) {
|
||||
if !plainText.MatchString(v) {
|
||||
say(kind, name, "%s holds a quote, a backslash or a line break", field)
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(c.Identities) {
|
||||
id := c.Identities[name]
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
if !entryName.MatchString(name) {
|
||||
say(kindIdentities, name, "a name is lower-case letters and digits, starting with a letter")
|
||||
}
|
||||
if !accountName.MatchString(id.Account) {
|
||||
say(kindIdentities, name, "account names an entry of the setting accounts")
|
||||
}
|
||||
if id.Address == "" && id.Name != "" {
|
||||
say(kindIdentities, name, "the account's default identity (no address) takes its name from the setting accounts")
|
||||
}
|
||||
if id.Address != "" && !addressShape.MatchString(id.Address) {
|
||||
say(kindIdentities, name, "the address is not a mail address")
|
||||
}
|
||||
if id.ReplyTo != "" && !addressShape.MatchString(id.ReplyTo) {
|
||||
say(kindIdentities, name, "reply-to is not a mail address")
|
||||
}
|
||||
for field, v := range map[string]string{"name": id.Name, "organization": id.Organization} {
|
||||
text(kindIdentities, name, field, v)
|
||||
}
|
||||
for _, line := range id.Signature {
|
||||
if strings.ContainsAny(line, lineEnds) {
|
||||
say(kindIdentities, name, "a signature's line holds a line break: each line is its own element")
|
||||
break
|
||||
}
|
||||
}
|
||||
if id.Key != "" && !profileKey.MatchString(id.Key) {
|
||||
say(kindIdentities, name, "key is an identity key of the profile, such as id3")
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(c.Calendars) {
|
||||
cal := c.Calendars[name]
|
||||
if cal == nil {
|
||||
continue
|
||||
}
|
||||
if !entryName.MatchString(name) {
|
||||
say(kindCalendars, name, "a name is lower-case letters and digits, starting with a letter")
|
||||
}
|
||||
if why := davURL(cal.URL); why != "" {
|
||||
say(kindCalendars, name, "%s", why)
|
||||
}
|
||||
if cal.Color != "" && !colorWord.MatchString(cal.Color) {
|
||||
say(kindCalendars, name, "a colour is #rrggbb")
|
||||
}
|
||||
if cal.Invitations != "" && c.Identities[cal.Invitations] == nil {
|
||||
say(kindCalendars, name, "invitations names %s, which the setting identities does not hold", cal.Invitations)
|
||||
}
|
||||
text(kindCalendars, name, "name", cal.Name)
|
||||
text(kindCalendars, name, "user", cal.User)
|
||||
if cal.Key != "" && !tagKey.MatchString(cal.Key) {
|
||||
say(kindCalendars, name, "key is a calendar key of the profile")
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(c.AddressBooks) {
|
||||
ab := c.AddressBooks[name]
|
||||
if ab == nil {
|
||||
continue
|
||||
}
|
||||
if !entryName.MatchString(name) {
|
||||
say(kindAddressBooks, name, "a name is lower-case letters and digits, starting with a letter")
|
||||
}
|
||||
if why := davURL(ab.URL); why != "" {
|
||||
say(kindAddressBooks, name, "%s", why)
|
||||
}
|
||||
text(kindAddressBooks, name, "name", ab.Name)
|
||||
text(kindAddressBooks, name, "user", ab.User)
|
||||
if ab.Key != "" && !profileKey.MatchString(ab.Key) {
|
||||
say(kindAddressBooks, name, "key is an address book key of the profile")
|
||||
}
|
||||
}
|
||||
for _, key := range sortedKeys(c.Tags) {
|
||||
t := c.Tags[key]
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if !tagKey.MatchString(key) {
|
||||
say(kindTags, key, "a tag's key is letters, digits, $, _, . or -")
|
||||
}
|
||||
if t.Color != "" && !colorWord.MatchString(t.Color) {
|
||||
say(kindTags, key, "a colour is #rrggbb")
|
||||
}
|
||||
text(kindTags, key, "name", t.Name)
|
||||
}
|
||||
for _, account := range sortedKeys(c.Filters) {
|
||||
if !accountName.MatchString(account) {
|
||||
say(kindFilters, account, "filters are keyed by an account's name in the setting accounts")
|
||||
}
|
||||
for i, f := range c.Filters[account] {
|
||||
for _, p := range filterProblems(f) {
|
||||
say(kindFilters, account, "filter %d: %s", i+1, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterValue is what a filter's name, value or folder may hold: what its file can carry without quoting.
|
||||
var filterValue = regexp.MustCompile(`^[^"\\()\n\r]*$`)
|
||||
|
||||
func filterProblems(f Filter) []string {
|
||||
var out []string
|
||||
if f.Name == "" || !filterValue.MatchString(f.Name) {
|
||||
out = append(out, "a name, without a quote, a backslash or a parenthesis")
|
||||
}
|
||||
if len(f.When) == 0 {
|
||||
out = append(out, "when says when it runs: new-mail, manual, after-junk, sending, archiving or periodic")
|
||||
}
|
||||
for _, w := range f.When {
|
||||
if _, ok := filterWhen[w]; !ok {
|
||||
out = append(out, fmt.Sprintf("when %q is new-mail, manual, after-junk, sending, archiving or periodic", w))
|
||||
}
|
||||
}
|
||||
switch f.Match {
|
||||
case "all", "any":
|
||||
if len(f.Conditions) == 0 {
|
||||
out = append(out, "match all or any needs a condition")
|
||||
}
|
||||
case "every-message":
|
||||
if len(f.Conditions) > 0 {
|
||||
out = append(out, "match every-message takes no condition")
|
||||
}
|
||||
default:
|
||||
out = append(out, "match is all, any or every-message")
|
||||
}
|
||||
for i, c := range f.Conditions {
|
||||
if !filterFields[c.Field] && !(strings.HasPrefix(c.Field, `"`) && strings.HasSuffix(c.Field, `"`) && len(c.Field) > 2) {
|
||||
out = append(out, fmt.Sprintf("condition %d: the field %q is not one Betterbird filters on", i+1, c.Field))
|
||||
}
|
||||
if !filterOps[c.Op] {
|
||||
out = append(out, fmt.Sprintf("condition %d: the operator %q is not one of Betterbird's", i+1, c.Op))
|
||||
}
|
||||
if !filterValue.MatchString(c.Value) {
|
||||
out = append(out, fmt.Sprintf("condition %d: the value holds a quote, a backslash, a parenthesis or a line break", i+1))
|
||||
}
|
||||
}
|
||||
if len(f.Actions) == 0 {
|
||||
out = append(out, "a filter takes an action")
|
||||
}
|
||||
for i, a := range f.Actions {
|
||||
takes, ok := filterActions[a.Action]
|
||||
switch {
|
||||
case !ok:
|
||||
out = append(out, fmt.Sprintf("action %d: %q is not an action a declared filter takes", i+1, a.Action))
|
||||
case takes == "folder" && (a.Folder == "" || a.Value != ""):
|
||||
out = append(out, fmt.Sprintf("action %d: %s names a folder, by its path within the account", i+1, a.Action))
|
||||
case takes == "value" && (a.Value == "" || a.Folder != ""):
|
||||
out = append(out, fmt.Sprintf("action %d: %s takes a value", i+1, a.Action))
|
||||
case takes == "" && (a.Value != "" || a.Folder != ""):
|
||||
out = append(out, fmt.Sprintf("action %d: %s takes no value", i+1, a.Action))
|
||||
}
|
||||
if !filterValue.MatchString(a.Value) || !filterValue.MatchString(a.Folder) || strings.HasPrefix(a.Folder, "/") ||
|
||||
strings.Contains(a.Folder, "://") {
|
||||
out = append(out, fmt.Sprintf("action %d: a folder is a path within the account, and a value holds no quote, "+
|
||||
"backslash, parenthesis or line break", i+1))
|
||||
}
|
||||
if a.Action == "Forward" && !addressShape.MatchString(a.Value) {
|
||||
out = append(out, fmt.Sprintf("action %d: Forward names a mail address", i+1))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedFilterAccounts are the accounts filters are declared for.
|
||||
func (c Configuration) sortedFilterAccounts() []string {
|
||||
out := make([]string, 0, len(c.Filters))
|
||||
for a := range c.Filters {
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Betterbird's whole configuration (novox/hq ADR 0329, issue 586): identities, calendars, address books, filters
|
||||
// and tags declared mesh-wide, adopted or created as accounts are, filters owned whole per declared account, what
|
||||
// Betterbird rewrites written only while it is closed, derived from the nodes' profiles one layer at a time, and
|
||||
// shown with the operator's own values but never a credential.
|
||||
|
||||
// profileWithConfiguration adds to profileWithAccounts' profile: account1's directory and signature, CalDAV
|
||||
// calendars (one address twice), an ICS and an Owl calendar, a local and a CardDAV address book, two tags, and
|
||||
// account1's filter file holding one filter of the person's.
|
||||
func (f *fake) profileWithConfiguration() {
|
||||
f.t.Helper()
|
||||
prefs := f.read(profileDir+"/prefs.js") +
|
||||
`user_pref("mail.server.server1.directory-rel", "[ProfD]ImapMail/imap.example.org");` + "\n" +
|
||||
`user_pref("mail.server.server2.directory-rel", "[ProfD]Mail/Local Folders");` + "\n" +
|
||||
`user_pref("mail.identity.id1.htmlSigText", "Regards");` + "\n" +
|
||||
`user_pref("calendar.registry.c1.type", "caldav");` + "\n" +
|
||||
`user_pref("calendar.registry.c1.uri", "https://dav.example.org/cal/home/");` + "\n" +
|
||||
`user_pref("calendar.registry.c1.name", "Home");` + "\n" +
|
||||
`user_pref("calendar.registry.c1.color", "#3366ff");` + "\n" +
|
||||
`user_pref("calendar.registry.c2.type", "caldav");` + "\n" +
|
||||
`user_pref("calendar.registry.c2.uri", "https://dav.example.org/cal/shared/");` + "\n" +
|
||||
`user_pref("calendar.registry.c2.name", "Shared A");` + "\n" +
|
||||
`user_pref("calendar.registry.c3.type", "caldav");` + "\n" +
|
||||
`user_pref("calendar.registry.c3.uri", "https://dav.example.org/cal/shared/");` + "\n" +
|
||||
`user_pref("calendar.registry.c3.name", "Shared B");` + "\n" +
|
||||
`user_pref("calendar.registry.c4.type", "ics");` + "\n" +
|
||||
`user_pref("calendar.registry.c4.uri", "https://feeds.example.org/secret-token-abc.ics");` + "\n" +
|
||||
`user_pref("calendar.registry.c4.name", "Holidays");` + "\n" +
|
||||
`user_pref("calendar.registry.c5.type", "owl");` + "\n" +
|
||||
`user_pref("calendar.registry.c5.uri", "owl://work@corp.example.com/calendar");` + "\n" +
|
||||
`user_pref("calendar.registry.c5.name", "Work");` + "\n" +
|
||||
`user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5");` + "\n" +
|
||||
`user_pref("ldap_2.servers.pab.description", "Personal Address Book");` + "\n" +
|
||||
`user_pref("ldap_2.servers.pab.dirType", 101);` + "\n" +
|
||||
`user_pref("ldap_2.servers.ab1.description", "Contacts");` + "\n" +
|
||||
`user_pref("ldap_2.servers.ab1.dirType", 102);` + "\n" +
|
||||
`user_pref("ldap_2.servers.ab1.carddav.url", "https://dav.example.org/card/me/");` + "\n" +
|
||||
`user_pref("ldap_2.servers.ab1.carddav.username", "me");` + "\n" +
|
||||
`user_pref("mailnews.tags.$label1.tag", "Important");` + "\n" +
|
||||
`user_pref("mailnews.tags.$label1.color", "#FF0000");` + "\n" +
|
||||
`user_pref("mailnews.tags.personal.tag", "Personal");` + "\n" +
|
||||
`user_pref("mailnews.tags.personal.color", "#00AA00");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
f.write(profileDir+"/ImapMail/imap.example.org/"+filterFileName, "version=\"9\"\nlogging=\"no\"\nname=\"Old\"\n"+
|
||||
"enabled=\"yes\"\ntype=\"17\"\naction=\"Mark read\"\ncondition=\"AND (from,contains,news)\"\n")
|
||||
f.write(profileDir+"/Mail/Local Folders/"+filterFileName, "version=\"9\"\nlogging=\"no\"\n")
|
||||
f.write(profileDir+"/calendar-data/cache.sqlite", "events")
|
||||
}
|
||||
|
||||
const declaredConfiguration = `{
|
||||
"identities": {
|
||||
"mail": {"account": "mail", "signature": ["-- ", "Other"], "signature-above-quote": true},
|
||||
"mailalias": {"account": "mail", "address": "alias@mail.example.org", "name": "Alias", "reply-to": "other@mail.example.org"}
|
||||
},
|
||||
"calendars": {
|
||||
"home": {"name": "Home", "url": "https://dav.example.org/cal/home", "color": "#3366CC", "user": "other", "invitations": "mail"},
|
||||
"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},
|
||||
"shared": {"name": "Shared", "url": "https://dav.example.org/cal/shared/"}
|
||||
},
|
||||
"address-books": {
|
||||
"contacts": {"name": "Contacts", "url": "https://dav.example.org/card/me/", "user": "other"},
|
||||
"team": {"name": "Team", "url": "https://dav.example.org/card/team/"}
|
||||
},
|
||||
"filters": {}
|
||||
}`
|
||||
|
||||
const declaredTags = `{"tags": {"$label1": {"name": "Important", "color": "#ff0000", "order": 1}, "todo": {"name": "To do", "color": "#0000ff", "order": 2}}}`
|
||||
|
||||
func (f *fake) declareConfiguration(body, tags string) {
|
||||
f.write(testHome+"/"+configurationFile, body)
|
||||
f.write(testHome+"/"+tagsFile, tags)
|
||||
}
|
||||
|
||||
func entriesBy(u UserJS) map[string]resolvedEntry {
|
||||
out := map[string]resolvedEntry{}
|
||||
for _, e := range u.Configuration {
|
||||
out[e.Kind+"/"+e.Name] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// §2, §4, §6: the settings' shapes; a credential, an address with a password, a key on a tag and the four new
|
||||
// prefixes in prefs are refused, said without the value.
|
||||
func TestTheSettingsRefuseACredentialAPasswordedAddressATagsKeyAndTheirPrefixesInPrefs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.declareConfiguration(declaredConfiguration, declaredTags)
|
||||
c, err := f.readConfiguration()
|
||||
if err != nil || len(c.Identities) != 2 || len(c.Calendars) != 3 || len(c.AddressBooks) != 2 || len(c.Tags) != 2 {
|
||||
t.Fatalf("the declared shapes are taken: %+v %v", c, err)
|
||||
}
|
||||
if c.Calendars["home"].Color != "#3366cc" || c.Tags["todo"].Color != "#0000FF" {
|
||||
t.Errorf("colours are said as Betterbird keeps them: %+v %+v", c.Calendars["home"], c.Tags["todo"])
|
||||
}
|
||||
for _, bad := range []struct{ conf, tags, says string }{
|
||||
{`{"calendars": {"home": {"name": "H", "url": "https://dav.example.org/c/", "password": "hunter2"}}}`, `{}`, "shaped like a credential"},
|
||||
{`{"calendars": {"home": {"name": "H", "url": "https://me:hunter2@dav.example.org/c/"}}}`, `{}`, "carries a password"},
|
||||
{`{"address-books": {"b": {"name": "B", "url": "https://me:hunter2@dav.example.org/b/"}}}`, `{}`, "carries a password"},
|
||||
{`{}`, `{"tags": {"todo": {"name": "To do", "key": "x"}}}`, "does not hold the shape"},
|
||||
{`{"tags": {"todo": {"name": "To do"}}}`, `{}`, "holds no tags"},
|
||||
{`{"identities": {"x": {"account": "mail", "name": "Me"}}}`, `{}`, "default identity"},
|
||||
{`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "all", "actions": [{"action": "Delete"}]}]}}`, `{}`, "needs a condition"},
|
||||
{`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "every-message", "actions": [{"action": "Move to folder", "folder": "imap://x@y/INBOX"}]}]}}`, `{}`, "path within the account"},
|
||||
} {
|
||||
f.declareConfiguration(bad.conf, bad.tags)
|
||||
_, err := f.readConfiguration()
|
||||
if err == nil || !strings.Contains(err.Error(), bad.says) || strings.Contains(err.Error(), "hunter2") {
|
||||
t.Errorf("%s %s: %v", bad.conf, bad.tags, err)
|
||||
}
|
||||
}
|
||||
// A key is an identity's, a calendar's or an address book's.
|
||||
f.declareConfiguration(`{"identities": {"x": {"account": "mail", "address": "x@mail.example.org", "key": "id9"}},
|
||||
"calendars": {"c": {"name": "C", "url": "https://dav.example.org/c/", "key": "c9"}},
|
||||
"address-books": {"b": {"name": "B", "url": "https://dav.example.org/b/", "key": "ab9"}}}`, `{}`)
|
||||
if _, err := f.readConfiguration(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// prefs refuses the four prefixes the new settings own, and the account's and a credential's as before.
|
||||
for _, name := range []string{"calendar.registry.c1.color", "calendar.list.sortOrder", "ldap_2.servers.ab1.description",
|
||||
"mailnews.tags.$label1.color", "mail.identity.id1.htmlSigText", "signon.rememberSignons"} {
|
||||
body, _ := json.Marshal(map[string]any{"prefs": map[string]any{name: "x"}})
|
||||
if _, err := parseDeclaredPrefs(body); err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
got, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14, "mail.chat.enabled": false, "calendar.alarms.show": null,
|
||||
"intl.date_time.pattern_override.date_short": "yyyy-MM-dd"}}`))
|
||||
if err != nil || !reflect.DeepEqual(got, map[string]string{"mail.uifontsize": "14", "mail.chat.enabled": "false",
|
||||
"intl.date_time.pattern_override.date_short": `"yyyy-MM-dd"`}) {
|
||||
t.Fatalf("one entry per preference, null taking one out: %v %v", got, err)
|
||||
}
|
||||
if _, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14.5}}`)); err == nil {
|
||||
t.Fatal("a number Betterbird does not keep is refused")
|
||||
}
|
||||
}
|
||||
|
||||
// §7, §9: each kind is adopted, created or refused as accounts are; the declared fields go into the user.js, and
|
||||
// what Betterbird rewrites waits for it to close; leaving the setting releases or removes, never events.
|
||||
func TestEachKindIsAdoptedOrCreatedAndWhatBetterbirdRewritesWaitsForItToClose(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
f.declareConfiguration(declaredConfiguration, declaredTags)
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
|
||||
// Betterbird runs: the user.js is placed, prefs.js and the filter files wait (§9).
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := entriesBy(u)
|
||||
for want, how := range map[string][2]string{
|
||||
"identities/mail": {adopted, "id1"}, "identities/mailalias": {created, "ident_mesh_mailalias"},
|
||||
"calendars/home": {adopted, "c1"}, "calendars/work": {created, "mesh_work"}, "calendars/shared": {refused, ""},
|
||||
"address-books/contacts": {adopted, "ab1"}, "address-books/team": {created, "mesh_team"},
|
||||
"tags/$label1": {adopted, "$label1"}, "tags/todo": {created, "todo"},
|
||||
} {
|
||||
if e := by[want]; e.How != how[0] || e.Key != how[1] {
|
||||
t.Errorf("%s: %+v, want %v", want, e, how)
|
||||
}
|
||||
}
|
||||
if why := by["calendars/shared"].Why; !strings.Contains(why, "2 entries") || !strings.Contains(why, "c2, c3") ||
|
||||
!strings.Contains(why, "key in this node's layer") {
|
||||
t.Errorf("an ambiguous match is refused with the keys and the remedy: %s", why)
|
||||
}
|
||||
placed := f.read(profileDir + "/user.js")
|
||||
for _, want := range []string{
|
||||
`user_pref("mail.identity.id1.htmlSigText", "-- \nOther");`,
|
||||
`user_pref("mail.identity.id1.reply_on_top", 1);`,
|
||||
`user_pref("mail.identity.id1.sig_bottom", false);`,
|
||||
`user_pref("mail.identity.ident_mesh_mailalias.useremail", "alias@mail.example.org");`,
|
||||
`user_pref("mail.identity.ident_mesh_mailalias.smtpServer", "smtp1");`,
|
||||
`user_pref("calendar.registry.c1.color", "#3366cc");`,
|
||||
`user_pref("calendar.registry.c1.imip.identity.key", "id1");`,
|
||||
`user_pref("calendar.registry.mesh_work.type", "caldav");`,
|
||||
`user_pref("ldap_2.servers.ab1.carddav.username", "other");`,
|
||||
`user_pref("ldap_2.servers.mesh_team.dirType", 102);`,
|
||||
`user_pref("mailnews.tags.todo.color", "#0000FF");`,
|
||||
`user_pref("mailnews.tags.todo.ordinal", "1002");`,
|
||||
} {
|
||||
if !strings.Contains(placed, want) {
|
||||
t.Errorf("the user.js lacks %s", want)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"calendar.registry.c2.", "calendar.registry.c3.", "calendar.registry.c4.", "calendar.registry.c5.",
|
||||
"ldap_2.servers.pab.", "mailnews.tags.personal.", "calendar.list.sortOrder"} {
|
||||
if strings.Contains(placed, never) {
|
||||
t.Errorf("the user.js writes %s, which nothing declares or which is refused", never)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(u.Closed, "not written") || !strings.Contains(u.Closed, "running") ||
|
||||
u.Waiting[kindIdentities] != 1 || u.Waiting[kindCalendars] != 1 {
|
||||
t.Fatalf("while Betterbird runs prefs.js is not written, and the waiting changes are counted: %q %v", u.Closed, u.Waiting)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != before {
|
||||
t.Fatal("prefs.js changed while Betterbird runs")
|
||||
}
|
||||
ch, _ := f.Check()
|
||||
waits := false
|
||||
for _, x := range ch.Findings {
|
||||
waits = waits || (strings.Contains(x.What, "change(s) to calendars are not written yet") && strings.Contains(x.Do, "betterbird_restart"))
|
||||
}
|
||||
if !waits {
|
||||
t.Fatalf("the check counts the waiting changes per kind: %+v", ch.Findings)
|
||||
}
|
||||
|
||||
// A key in the node's layer settles the ambiguous calendar; one the profile does not hold is refused.
|
||||
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
|
||||
`"url": "https://dav.example.org/cal/shared/", "key": "c3"}`, 1), declaredTags)
|
||||
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].Key != "c3" || entriesBy(u)["calendars/shared"].How != adopted {
|
||||
t.Fatalf("the named key is adopted: %+v", entriesBy(u)["calendars/shared"])
|
||||
}
|
||||
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
|
||||
`"url": "https://dav.example.org/cal/shared/", "key": "c9"}`, 1), declaredTags)
|
||||
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused || !strings.Contains(entriesBy(u)["calendars/shared"].Why, "c9") {
|
||||
t.Fatalf("a key the profile does not hold is refused: %+v", entriesBy(u)["calendars/shared"])
|
||||
}
|
||||
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
|
||||
`"url": "https://dav.example.org/cal/shared/", "key": "c1"}`, 1), declaredTags)
|
||||
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused {
|
||||
t.Fatalf("a key whose entry does not match is refused: %+v", entriesBy(u)["calendars/shared"])
|
||||
}
|
||||
f.declareConfiguration(declaredConfiguration, declaredTags)
|
||||
|
||||
// Closed: the lists gain the module's own keys at their end, every other line as it was.
|
||||
stopBetterbird(t, f)
|
||||
f.write(profileDir+"/lock", "")
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "locked") {
|
||||
t.Fatalf("the profile's lock holds the writes back: %v", err)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, profileDir, "lock"))
|
||||
if err := f.Prepare(); err != nil && !strings.Contains(err.Error(), "calendars shared is refused") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := f.read(profileDir + "/prefs.js")
|
||||
for _, want := range []string{`user_pref("mail.account.account1.identities", "id1,ident_mesh_mailalias");`,
|
||||
`user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5 mesh_work");`} {
|
||||
if !strings.Contains(after, want) {
|
||||
t.Errorf("prefs.js lacks %s:\n%s", want, after)
|
||||
}
|
||||
}
|
||||
bl, al := strings.Split(before, "\n"), strings.Split(after, "\n")
|
||||
for i := range bl {
|
||||
if bl[i] != al[i] && !strings.Contains(bl[i], ".identities") && !strings.Contains(bl[i], "sortOrder") {
|
||||
t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving: an adopted entry is released (its lines go, the profile keeps it), a created one's preferences are
|
||||
// removed; the calendar's events stay.
|
||||
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
|
||||
`user_pref("calendar.registry.mesh_work.calendarSyncState", "x");`+"\n"+`user_pref("mailnews.tags.todo.tag", "To do");`+"\n")
|
||||
f.declareConfiguration(`{"identities": {"mail": {"account": "mail"}}}`, `{"tags": {}}`)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := f.read(profileDir + "/prefs.js")
|
||||
for _, gone := range []string{"mesh_work", "ident_mesh_mailalias", "mailnews.tags.todo."} {
|
||||
if strings.Contains(final, gone) {
|
||||
t.Errorf("what the module created and the setting left is removed: %s is still there", gone)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{`calendar.registry.c1.uri`, `ldap_2.servers.ab1.carddav.url`, `mailnews.tags.$label1.tag`,
|
||||
`calendar.registry.c4.uri`} {
|
||||
if !strings.Contains(final, kept) {
|
||||
t.Errorf("released or undeclared, %s stays the person's", kept)
|
||||
}
|
||||
}
|
||||
if f.read(profileDir+"/calendar-data/cache.sqlite") != "events" {
|
||||
t.Error("a calendar's events are never touched")
|
||||
}
|
||||
if placed := f.read(profileDir + "/user.js"); strings.Contains(placed, "calendar.registry.c1.") || strings.Contains(placed, "ab1") {
|
||||
t.Errorf("a released entry's lines leave the user.js:\n%s", placed)
|
||||
}
|
||||
}
|
||||
|
||||
const declaredFilters = `{"filters": {"mail": [
|
||||
{"name": "Receipts", "enabled": true, "when": ["new-mail", "manual"], "match": "all",
|
||||
"conditions": [{"field": "subject", "op": "contains", "value": "receipt"}],
|
||||
"actions": [{"action": "Move to folder", "folder": "INBOX/Receipts"}, {"action": "Forward", "value": "archive@mail.example.org"}]},
|
||||
{"name": "Everything", "enabled": false, "when": ["manual"], "match": "every-message", "actions": [{"action": "Mark read"}]}
|
||||
]}}`
|
||||
|
||||
const renderedFilters = "version=\"9\"\nlogging=\"no\"\n" +
|
||||
"name=\"Receipts\"\nenabled=\"yes\"\ntype=\"17\"\naction=\"Move to folder\"\n" +
|
||||
"actionValue=\"imap://other2@imap.example.org/INBOX/Receipts\"\naction=\"Forward\"\nactionValue=\"archive@mail.example.org\"\n" +
|
||||
"condition=\"AND (subject,contains,receipt)\"\n" +
|
||||
"name=\"Everything\"\nenabled=\"no\"\ntype=\"16\"\naction=\"Mark read\"\ncondition=\"ALL\"\n"
|
||||
|
||||
// §8: an account named in filters has its msgFilterRules.dat written whole from the list, the replaced file kept;
|
||||
// an account never named keeps its file byte for byte; one taken out keeps the file it last had.
|
||||
func TestFiltersAreOwnedWholePerDeclaredAccount(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
f.declareConfiguration(declaredFilters, `{"tags": {}}`)
|
||||
filters := profileDir + "/ImapMail/imap.example.org/" + filterFileName
|
||||
local := profileDir + "/Mail/Local Folders/" + filterFileName
|
||||
old, oldLocal := f.read(filters), f.read(local)
|
||||
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Waiting[kindFilters] != 1 || f.read(filters) != old {
|
||||
t.Fatalf("while Betterbird runs the filter file waits: %v", u.Waiting)
|
||||
}
|
||||
stopBetterbird(t, f)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.read(filters); got != renderedFilters {
|
||||
t.Fatalf("the file is the declared list, its folder this node's address:\n%s", got)
|
||||
}
|
||||
keptPath := testHome + "/.config/betterbird/kept/60016jsp.default-default/mail." + filterFileName
|
||||
if f.read(keptPath) != old {
|
||||
t.Fatal("the replaced file is kept in the module's own directory")
|
||||
}
|
||||
if f.read(local) != oldLocal {
|
||||
t.Fatal("an account never named keeps its file byte for byte")
|
||||
}
|
||||
if parsed := parseFilters(renderedFilters, "imap://other2@imap.example.org"); len(parsed.NotDeclarable) != 0 ||
|
||||
mustJSON(parsed.Filters) != mustJSON(mustConfig(t, declaredFilters).Filters["mail"]) {
|
||||
t.Fatalf("the file reads back as the declared list: %+v", parsed)
|
||||
}
|
||||
|
||||
// A filter the person adds in Betterbird is drift first, and gone at the next apply; the kept file stays the first.
|
||||
f.write(filters, renderedFilters+"name=\"Mine\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n")
|
||||
d, _ := f.Drift(kindFilters)
|
||||
if len(d.Kinds) != 1 || len(d.Kinds[0].Declared) != 1 || d.Kinds[0].Declared[0].State != "differs" ||
|
||||
!strings.Contains(mustJSON(d.Kinds[0].Declared[0].Differs), "Mine") {
|
||||
t.Fatalf("drift shows the added filter: %+v", d)
|
||||
}
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.read(filters) != renderedFilters || f.read(keptPath) != old {
|
||||
t.Fatal("the added filter is gone, and the kept file is still the one first replaced")
|
||||
}
|
||||
|
||||
// Taken out of filters: the module stops writing, the last file stays the person's, the kept copy is named.
|
||||
f.declareConfiguration(`{"filters": {}}`, `{"tags": {}}`)
|
||||
f.write(filters, renderedFilters+"name=\"Later\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n")
|
||||
lastHad := f.read(filters)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.read(filters) != lastHad || f.read(keptPath) != old {
|
||||
t.Fatal("an account taken out keeps its file byte for byte, and the kept copy stays")
|
||||
}
|
||||
l, _ := f.List(kindFilters)
|
||||
if l.Kept["mail"] != "~/.config/betterbird/kept/60016jsp.default-default/mail."+filterFileName {
|
||||
t.Fatalf("betterbird_filters says where the kept copy is: %+v", l.Kept)
|
||||
}
|
||||
d, _ = f.Drift(kindFilters)
|
||||
if len(d.Kinds[0].Declared) != 0 || len(d.Kinds[0].Undeclared) != 0 {
|
||||
t.Fatalf("drift no longer counts that account's filters: %+v", d.Kinds[0])
|
||||
}
|
||||
}
|
||||
|
||||
func mustConfig(t *testing.T, body string) Configuration {
|
||||
t.Helper()
|
||||
var c Configuration
|
||||
if err := decodeInto([]byte(body), configurationFile, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// §11: per kind and entry, the field that differs with both values; an undeclared entry is listed; once the module's
|
||||
// user.js is placed, what it declares holds.
|
||||
func TestDriftNamesEachDifferingFieldAndTheUndeclaredEntries(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
f.declareConfiguration(strings.Replace(declaredConfiguration, `"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},`, "", 1), declaredTags)
|
||||
d, err := f.Drift("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]KindDrift{}
|
||||
for _, k := range d.Kinds {
|
||||
by[k.Kind] = k
|
||||
}
|
||||
field := func(kind, name, f string) *fieldDiff {
|
||||
for _, e := range by[kind].Declared {
|
||||
if e.Name == name {
|
||||
for _, x := range e.Differs {
|
||||
if x.Field == f {
|
||||
return &x
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if x := field(kindIdentities, "mail", "signature"); x == nil || mustJSON(x.Profile) != `["Regards"]` {
|
||||
t.Errorf("the signature differs, with both values: %+v", by[kindIdentities])
|
||||
}
|
||||
if x := field(kindCalendars, "home", "color"); x == nil || x.Declared != "#3366cc" || x.Profile != "#3366ff" {
|
||||
t.Errorf("the colour differs: %+v", by[kindCalendars])
|
||||
}
|
||||
if x := field(kindAddressBooks, "contacts", "user"); x == nil || x.Profile != "me" {
|
||||
t.Errorf("the user differs: %+v", by[kindAddressBooks])
|
||||
}
|
||||
states := map[string]string{}
|
||||
for _, k := range d.Kinds {
|
||||
for _, e := range k.Declared {
|
||||
states[k.Kind+"/"+e.Name] = e.State
|
||||
}
|
||||
}
|
||||
if states["tags/todo"] != "lacks" || states["calendars/shared"] != refused || states["tags/$label1"] != "holds" ||
|
||||
states["address-books/team"] != "lacks" {
|
||||
t.Errorf("lacks, refused and holds: %v", states)
|
||||
}
|
||||
undeclared := mustJSON(d)
|
||||
for _, want := range []string{`"key":"personal"`, `"key":"c2"`, `"key":"c3"`} {
|
||||
if !strings.Contains(undeclared, want) {
|
||||
t.Errorf("the undeclared entries are listed: lacks %s", want)
|
||||
}
|
||||
}
|
||||
if _, err := f.userJS(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _ = f.Drift("")
|
||||
for _, k := range d.Kinds {
|
||||
for _, e := range k.Declared {
|
||||
if e.State == "differs" {
|
||||
t.Errorf("once the user.js is placed, %s %s holds: %+v", k.Kind, e.Name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §1, §2, §13: the listing tools answer the operator's own values, Owl's marked, an ICS calendar by its name only,
|
||||
// and never a credential: through the tools, a password in an address and a field named for one are withheld.
|
||||
func TestTheListingsShowTheValuesOwlsMarkedAnICSByNameAndNoCredential(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
|
||||
`user_pref("calendar.registry.c6.type", "caldav");`+"\n"+
|
||||
`user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+
|
||||
`user_pref("calendar.registry.c6.name", "Old");`+"\n")
|
||||
f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"https://dav.example.org","encryptedPassword":"hunter3"}]}`)
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
f.declareConfiguration(declaredConfiguration, declaredTags)
|
||||
old := machine
|
||||
machine = f.Machine
|
||||
t.Cleanup(func() { machine = old })
|
||||
answers := map[string]string{}
|
||||
for _, tool := range tools() {
|
||||
if strings.HasPrefix(tool.Name, "betterbird_") && contains([]string{"betterbird_identities", "betterbird_calendars",
|
||||
"betterbird_address_books", "betterbird_filters", "betterbird_tags", "betterbird_accounts", "betterbird_drift"}, tool.Name) {
|
||||
out, err := tool.Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name, err)
|
||||
}
|
||||
answers[tool.Name] = mustJSON(out)
|
||||
}
|
||||
}
|
||||
cals := answers["betterbird_calendars"]
|
||||
for _, shown := range []string{"https://dav.example.org/cal/home/", `"name":"Work","url":"owl://work@corp.example.com/calendar"`,
|
||||
`"owl":true`, `"name":"Holidays"`} {
|
||||
if !strings.Contains(cals, shown) {
|
||||
t.Errorf("betterbird_calendars shows %s: %s", shown, cals)
|
||||
}
|
||||
}
|
||||
if strings.Contains(cals, "secret-token") {
|
||||
t.Error("an ICS calendar is listed by its name only")
|
||||
}
|
||||
for name, a := range answers {
|
||||
if strings.Contains(a, "hunter2") || strings.Contains(a, "hunter3") {
|
||||
t.Errorf("%s carries a credential: %s", name, a)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(cals, "https://other:‹withheld›@dav.example.org/cal/x/") {
|
||||
t.Errorf("an address's password is shown as ‹withheld›: %s", cals)
|
||||
}
|
||||
for _, shown := range []string{`"signature":["Regards"]`, "other@mail.example.org"} {
|
||||
if !strings.Contains(answers["betterbird_identities"], shown) {
|
||||
t.Errorf("betterbird_identities shows %s", shown)
|
||||
}
|
||||
}
|
||||
if got := mustJSON(credentialChecked(map[string]any{"name": "x", "password": "p", "nested": []any{map[string]any{"oauthToken": "t"}}})); got !=
|
||||
`{"name":"x","nested":[{"oauthToken":"‹withheld›"}],"password":"‹withheld›"}` {
|
||||
t.Errorf("a field named for a credential is withheld: %s", got)
|
||||
}
|
||||
// The start's words reach the journal: a refusal names kinds, names and keys, never an address.
|
||||
stopBetterbird(t, f)
|
||||
err := f.Prepare()
|
||||
if err == nil || !strings.Contains(err.Error(), "calendars shared is refused") || strings.Contains(err.Error(), "example.org") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
package main
|
||||
|
||||
// The five settings derived from the desktop nodes' profiles and proposed for the operator's Approve, one layer
|
||||
// at a time (novox/hq ADR 0329 §10, §12).
|
||||
//
|
||||
// betterbird_derive asks every desktop node that holds Betterbird for what its profile holds (its own derive with
|
||||
// here), and lays the answers out per layer: an entry found on every such node goes into the mesh-wide layer, with
|
||||
// the value of the node the derive was called on, and the answer lists every field another node holds differently,
|
||||
// which then shows as drift there; an entry found on some nodes only goes into each of those nodes' layers, so
|
||||
// nothing is added to another node until the operator moves it. With propose it proposes the next layer through
|
||||
// the controller's settings verb: first the mesh-wide one, then each node's that needs one, each only once no
|
||||
// proposal of the module's is open, against the layer as it then stands, which it carries whole beside the derived
|
||||
// kinds (a layer is replaced whole). The ask shows the values in their shape (ADR 0277 §6); the operator reads them
|
||||
// whole with `settings proposals <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
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a
|
||||
// time, carrying the layer as it stands.
|
||||
|
||||
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
|
||||
// controller answers them; layers are the controller's layers, open the proposals that wait.
|
||||
type derivingMesh struct {
|
||||
t *testing.T
|
||||
shanks *fake
|
||||
layers map[string]map[string]any
|
||||
open bool
|
||||
proposed []map[string]any
|
||||
asked []string
|
||||
asleep bool
|
||||
// garbled answers the layer in words that are not its JSON.
|
||||
garbled bool
|
||||
}
|
||||
|
||||
func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
|
||||
d.asked = append(d.asked, key)
|
||||
args, _ := body.(map[string]any)
|
||||
answer := func(ok bool, text string) (json.RawMessage, error) {
|
||||
b, _ := json.Marshal(map[string]any{"ok": ok, "output": text})
|
||||
return b, nil
|
||||
}
|
||||
switch {
|
||||
case key == "seat:mesh-controller.nodes":
|
||||
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
|
||||
case key == "seat:mesh-controller.node":
|
||||
if args["node"] == "novox" {
|
||||
return answer(true, "novox\n assigned: postgres, nats\n")
|
||||
}
|
||||
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
|
||||
case key == "betterbird.betterbird_derive@shanks":
|
||||
if d.asleep {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
|
||||
if err != nil {
|
||||
d.t.Fatal(err)
|
||||
}
|
||||
text, _ := json.Marshal(credentialChecked(DeriveAnswer{Here: &here}))
|
||||
return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}})
|
||||
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
|
||||
if d.open {
|
||||
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
|
||||
"scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
|
||||
}
|
||||
return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n")
|
||||
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
|
||||
d.proposed = append(d.proposed, args)
|
||||
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
|
||||
case key == "seat:mesh-controller.settings":
|
||||
node, _ := args["node"].(string)
|
||||
if d.garbled {
|
||||
return answer(true, "Settings of betterbird on "+node+":\n accounts = {…}\n")
|
||||
}
|
||||
layer := d.layers[node]
|
||||
if layer == nil {
|
||||
where := node
|
||||
if where == "" {
|
||||
where = "the whole mesh"
|
||||
}
|
||||
return answer(true, "betterbird on "+where+": no layer — the module's definition says\nbetterbird on "+where+", every value")
|
||||
}
|
||||
b, _ := json.MarshalIndent(layer, "", " ")
|
||||
return answer(true, string(b)+"\n approved by the operator\nbetterbird on the whole mesh, every value and where it comes from:\n")
|
||||
}
|
||||
d.t.Fatalf("asked %s %v", key, body)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func stringsOf(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case []string:
|
||||
return x
|
||||
case []any:
|
||||
var out []string
|
||||
for _, s := range x {
|
||||
out = append(out, s.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
|
||||
`user_pref("calendar.registry.c6.type", "caldav");`+"\n"+
|
||||
`user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+
|
||||
`user_pref("calendar.registry.c6.name", "Old");`+"\n")
|
||||
f.declareAccounts(adoptable)
|
||||
links(t, nil)
|
||||
f2 := newFake(t)
|
||||
f2.profileWithAccounts()
|
||||
f2.profileWithConfiguration()
|
||||
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
|
||||
f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+
|
||||
`user_pref("calendar.registry.s1.type", "caldav");`+"\n"+
|
||||
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
|
||||
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
|
||||
f2.declareAccounts(adoptable)
|
||||
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "g14" }
|
||||
asker = mesh.ask
|
||||
|
||||
a, err := f.Derive(nil, false, false, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
|
||||
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
|
||||
}
|
||||
whole := mustJSON(a)
|
||||
mw := a.Layers[meshWide]
|
||||
ids, _ := mw[kindIdentities].(map[string]any)
|
||||
if mail, _ := ids["mail"].(map[string]any); mail == nil || mustJSON(mail["signature"]) != `["Regards"]` || mail["address"] != nil {
|
||||
t.Errorf("an identity on both nodes is mesh-wide with this node's value, the default without its address: %v", ids)
|
||||
}
|
||||
cals, _ := mw[kindCalendars].(map[string]any)
|
||||
if cals["home"] == nil || cals["standup"] != nil {
|
||||
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
|
||||
}
|
||||
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
|
||||
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
|
||||
}
|
||||
tags, _ := mw[kindTags].(map[string]any)
|
||||
if l1, _ := tags["$label1"].(map[string]any); l1 == nil || l1["color"] != "#FF0000" {
|
||||
t.Errorf("the tag is mesh-wide with this node's colour: %v", tags)
|
||||
}
|
||||
diffs := map[string]difference{}
|
||||
for _, d := range a.Differences {
|
||||
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
|
||||
}
|
||||
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
|
||||
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
|
||||
}
|
||||
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
|
||||
t.Errorf("%+v", a.Differences)
|
||||
}
|
||||
for _, never := range []string{"secret-token", "hunter2", "owl://", `"Work"`} {
|
||||
if strings.Contains(mustJSON(a.Layers), never) {
|
||||
t.Errorf("no ICS address, credential or Owl calendar is derived: %s in %s", never, mustJSON(a.Layers))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(whole, "Holidays") || !strings.Contains(whole, "‹withheld›") || strings.Contains(mustJSON(credentialChecked(a)), "hunter2") {
|
||||
t.Errorf("what is left out is named, an ICS calendar by its name and a password as ‹withheld›: %v", a.NotDerived)
|
||||
}
|
||||
if len(mesh.proposed) != 0 {
|
||||
t.Fatal("without propose nothing is proposed")
|
||||
}
|
||||
|
||||
// Propose: the mesh-wide layer first, as it stands with the derived kinds, and the operator's setup of prefs.
|
||||
prefs := map[string]any{}
|
||||
for n, v := range shippedDefaultsWant {
|
||||
prefs[n], _ = prefValue(v)
|
||||
}
|
||||
a, err = f.Derive(nil, false, true, prefs, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mesh.proposed) != 1 || mesh.proposed[0]["node"] != nil || mesh.proposed[0]["replace"] != "true" || a.Next != meshWide {
|
||||
t.Fatalf("the mesh-wide layer is proposed first: %v %s", mesh.proposed, a.Next)
|
||||
}
|
||||
var values map[string]any
|
||||
_ = json.Unmarshal([]byte(mesh.proposed[0]["values"].(string)), &values)
|
||||
if values["accounts"] == nil || values[kindIdentities] == nil || values[kindTags] == nil || values["prefs"] == nil {
|
||||
t.Fatalf("the layer as it stands, with every derived kind and prefs: %v", values)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefs": values["prefs"]})
|
||||
if got, err := parseDeclaredPrefs(body); err != nil || !reflect.DeepEqual(got, shippedDefaultsWant) {
|
||||
t.Fatalf("the operator's 21 preferences, one entry each: %v %v", got, err)
|
||||
}
|
||||
if !strings.Contains(a.Proposed, "sab12") {
|
||||
t.Fatalf("%s", a.Proposed)
|
||||
}
|
||||
|
||||
// While it waits nothing more is proposed.
|
||||
mesh.open = true
|
||||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 {
|
||||
t.Fatalf("one open proposal of the module's at a time: %v", err)
|
||||
}
|
||||
// Approved: the mesh-wide layer holds it, so the next layer is shanks's own.
|
||||
mesh.open = false
|
||||
mesh.layers[""] = values
|
||||
a, err = f.Derive(nil, false, true, prefs, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
|
||||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
|
||||
t.Fatalf("then the node's own layer: %v", mesh.proposed)
|
||||
}
|
||||
var shanks map[string]any
|
||||
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mesh.layers["shanks"] = shanks
|
||||
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
|
||||
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
|
||||
}
|
||||
|
||||
// A desktop node that does not answer: derived without it, never proposed.
|
||||
mesh.asleep = true
|
||||
a, err = f.Derive(nil, false, true, nil, meshWide)
|
||||
if err == nil || len(a.NotHeard) != 1 || !strings.Contains(err.Error(), "did not answer") || len(mesh.proposed) != 2 {
|
||||
t.Fatalf("a layer is never proposed without a desktop node: %v %v", err, a.NotHeard)
|
||||
}
|
||||
|
||||
// A key to adopt is a node's: the mesh-wide layer's proposal refuses one.
|
||||
if err := proposable(map[string]any{kindCalendars: map[string]any{"c": map[string]any{"name": "C",
|
||||
"url": "https://dav.example.org/c/", "key": "c3"}}}, true); err == nil || !strings.Contains(err.Error(), "node's own layer") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review 121: a layer the controller answers in any other words than its JSON or "no layer" refuses the proposal,
|
||||
// because a proposal replaces the layer whole and would drop accounts.
|
||||
func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.profileWithConfiguration()
|
||||
f.declareAccounts(adoptable)
|
||||
links(t, nil)
|
||||
f2 := newFake(t)
|
||||
f2.profileWithAccounts()
|
||||
f2.declareAccounts(adoptable)
|
||||
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "g14" }
|
||||
asker = mesh.ask
|
||||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||||
len(mesh.proposed) != 0 {
|
||||
t.Fatalf("the derive refuses: %v %v", err, mesh.proposed)
|
||||
}
|
||||
if _, err := f.ProfileAccounts(true); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||||
len(mesh.proposed) != 0 {
|
||||
t.Fatalf("betterbird_accounts refuses: %v %v", err, mesh.proposed)
|
||||
}
|
||||
if _, err := readLayer(""); err == nil {
|
||||
t.Fatal("an unreadable layer is an error, never an empty layer")
|
||||
}
|
||||
mesh.garbled = false
|
||||
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package main
|
||||
|
||||
// An account's filters, owned whole per declared account (novox/hq ADR 0329 §8).
|
||||
//
|
||||
// Betterbird keeps an account's filters in msgFilterRules.dat in the account's own directory: a header, then per
|
||||
// filter its name, whether it is on, when it runs (a bit mask), its actions and one condition line. For an account
|
||||
// the setting filters names, the module writes that file from the declared list, naming each folder by its path
|
||||
// within the account and turning it into the folder's address on this node; a filter in the file the setting does
|
||||
// not hold is gone after the apply. Before its first write on a node the module keeps the file it replaces, in its
|
||||
// own directory in the home (ADR 0182). It writes only while Betterbird does not run, because Betterbird rewrites
|
||||
// the file itself (§9).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// filterFileName is the file an account's filters are in.
|
||||
const filterFileName = "msgFilterRules.dat"
|
||||
|
||||
// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "".
|
||||
func accountDirectory(profile string, prefs map[string]string, server string) string {
|
||||
p := "mail.server." + server + "."
|
||||
if rel := unquote(prefs[p+"directory-rel"]); strings.HasPrefix(rel, "[ProfD]") {
|
||||
dir := filepath.Join(profile, filepath.FromSlash(strings.TrimPrefix(rel, "[ProfD]")))
|
||||
if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) {
|
||||
return dir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// folderBase is the address of an account's root folder on this node: imap://user@host for IMAP, mailbox://
|
||||
// for POP3 and local folders, the user's @ written %40 as Betterbird writes it.
|
||||
func folderBase(prefs map[string]string, server string) string {
|
||||
p := "mail.server." + server + "."
|
||||
kind := unquote(prefs[p+"type"])
|
||||
host := unquote(prefs[p+"hostname"])
|
||||
user := unquote(prefs[p+"userName"])
|
||||
scheme := "mailbox"
|
||||
switch kind {
|
||||
case "imap":
|
||||
scheme = "imap"
|
||||
case "none":
|
||||
host, user = "Local Folders", "nobody"
|
||||
}
|
||||
return scheme + "://" + strings.ReplaceAll(url.PathEscape(user), "@", "%40") + "@" + url.PathEscape(host)
|
||||
}
|
||||
|
||||
// folderAddress is a folder's address on this node from its path within the account.
|
||||
func folderAddress(base, path string) string {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for i, p := range parts {
|
||||
parts[i] = url.PathEscape(p)
|
||||
}
|
||||
return base + "/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// folderPath is a folder's path within the account from its address, when the address is the account's.
|
||||
func folderPath(base, address string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(address, base+"/")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
for i, p := range parts {
|
||||
u, err := url.PathUnescape(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
parts[i] = u
|
||||
}
|
||||
return strings.Join(parts, "/"), true
|
||||
}
|
||||
|
||||
// whenOf is a filter's type mask from its words; whenWords the reverse, or false when the mask holds a bit no
|
||||
// word says (a news filter's).
|
||||
func whenOf(words []string) int {
|
||||
n := 0
|
||||
for _, w := range words {
|
||||
n |= filterWhen[w]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func whenWords(mask int) ([]string, bool) {
|
||||
var out []string
|
||||
left := mask
|
||||
for _, w := range []string{"new-mail", "manual", "after-junk", "sending", "archiving", "periodic"} {
|
||||
if mask&filterWhen[w] != 0 {
|
||||
out = append(out, w)
|
||||
left &^= filterWhen[w]
|
||||
}
|
||||
}
|
||||
return out, left == 0
|
||||
}
|
||||
|
||||
// renderFilters is the file Betterbird reads for a declared list of filters on this node.
|
||||
func renderFilters(filters []Filter, base string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("version=\"9\"\nlogging=\"no\"\n")
|
||||
for _, f := range filters {
|
||||
fmt.Fprintf(&b, "name=\"%s\"\n", f.Name)
|
||||
fmt.Fprintf(&b, "enabled=\"%s\"\n", map[bool]string{true: "yes", false: "no"}[f.Enabled])
|
||||
fmt.Fprintf(&b, "type=\"%d\"\n", whenOf(f.When))
|
||||
for _, a := range f.Actions {
|
||||
fmt.Fprintf(&b, "action=\"%s\"\n", a.Action)
|
||||
switch {
|
||||
case a.Folder != "":
|
||||
fmt.Fprintf(&b, "actionValue=\"%s\"\n", folderAddress(base, a.Folder))
|
||||
case a.Value != "":
|
||||
fmt.Fprintf(&b, "actionValue=\"%s\"\n", a.Value)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "condition=\"%s\"\n", conditionText(f))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func conditionText(f Filter) string {
|
||||
if f.Match == "every-message" {
|
||||
return "ALL"
|
||||
}
|
||||
join := "AND"
|
||||
if f.Match == "any" {
|
||||
join = "OR"
|
||||
}
|
||||
var parts []string
|
||||
for _, c := range f.Conditions {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s,%s,%s)", join, c.Field, c.Op, c.Value))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
var (
|
||||
filterLine = regexp.MustCompile(`^([A-Za-z]+)="((?:[^"\\]|\\.)*)"$`)
|
||||
clause = regexp.MustCompile(`(AND|OR) \(([^,()]+),([^,()]+),([^()]*)\)`)
|
||||
)
|
||||
|
||||
// parsedFilters is a filter file read into the setting's shape. NotDeclarable names, by the filter's name, the
|
||||
// filters a setting cannot hold as they are (a folder of another account, an action of an add-on, a news filter).
|
||||
type parsedFilters struct {
|
||||
Filters []Filter
|
||||
NotDeclarable []string
|
||||
}
|
||||
|
||||
// parseFilters reads a filter file for an account whose folders are under base.
|
||||
func parseFilters(text, base string) parsedFilters {
|
||||
var out parsedFilters
|
||||
var cur *Filter
|
||||
bad := ""
|
||||
finish := func() {
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
if bad != "" {
|
||||
out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+bad)
|
||||
} else if p := filterProblems(*cur); len(p) > 0 {
|
||||
out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+p[0])
|
||||
} else {
|
||||
out.Filters = append(out.Filters, *cur)
|
||||
}
|
||||
cur, bad = nil, ""
|
||||
}
|
||||
s := bufio.NewScanner(strings.NewReader(text))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for s.Scan() {
|
||||
mm := filterLine.FindStringSubmatch(strings.TrimSpace(s.Text()))
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
field, value := mm[1], strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(mm[2])
|
||||
if field == "name" {
|
||||
finish()
|
||||
cur = &Filter{Name: value}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
switch field {
|
||||
case "enabled":
|
||||
cur.Enabled = value == "yes"
|
||||
case "type":
|
||||
n, _ := strconv.Atoi(value)
|
||||
words, ok := whenWords(n)
|
||||
if !ok && bad == "" {
|
||||
bad = "it runs on news or at a time no setting word says"
|
||||
}
|
||||
cur.When = words
|
||||
case "action":
|
||||
cur.Actions = append(cur.Actions, Action{Action: value})
|
||||
case "actionValue":
|
||||
if len(cur.Actions) == 0 {
|
||||
continue
|
||||
}
|
||||
a := &cur.Actions[len(cur.Actions)-1]
|
||||
if filterActions[a.Action] == "folder" {
|
||||
path, ok := folderPath(base, value)
|
||||
if !ok && bad == "" {
|
||||
bad = "it moves or copies to a folder of another account"
|
||||
}
|
||||
a.Folder = path
|
||||
} else {
|
||||
a.Value = value
|
||||
}
|
||||
case "condition":
|
||||
switch {
|
||||
case value == "ALL":
|
||||
cur.Match = "every-message"
|
||||
default:
|
||||
all := clause.FindAllStringSubmatch(value, -1)
|
||||
if len(all) == 0 && bad == "" {
|
||||
bad = "its condition cannot be read"
|
||||
}
|
||||
for _, c := range all {
|
||||
if c[1] == "OR" {
|
||||
cur.Match = "any"
|
||||
} else if cur.Match == "" {
|
||||
cur.Match = "all"
|
||||
}
|
||||
cur.Conditions = append(cur.Conditions, Condition{Field: c[2], Op: c[3], Value: c[4]})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
return out
|
||||
}
|
||||
|
||||
// filterDifferences says how a file's filters differ from the declared list, by place and name: missing,
|
||||
// not declared, or differing.
|
||||
func filterDifferences(declared, have []Filter) []string {
|
||||
var out []string
|
||||
for i := 0; i < len(declared) || i < len(have); i++ {
|
||||
switch {
|
||||
case i >= len(have):
|
||||
out = append(out, fmt.Sprintf("filter %d (%s) is declared and not in the file", i+1, declared[i].Name))
|
||||
case i >= len(declared):
|
||||
out = append(out, fmt.Sprintf("filter %d (%s) is in the file and not declared: removed at the next apply", i+1, have[i].Name))
|
||||
default:
|
||||
d, h := mustJSON(declared[i]), mustJSON(have[i])
|
||||
if d != h {
|
||||
out = append(out, fmt.Sprintf("filter %d: declared %s, the file holds %s", i+1, d, h))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keptFilterFile is where the module keeps the file it replaced at its first write of an account's filters.
|
||||
func (m *Machine) keptFilterFile(profileDir, account string) string {
|
||||
base := filepath.Base(profileDir)
|
||||
return m.home(".config/betterbird/kept", base, account+"."+filterFileName)
|
||||
}
|
||||
|
||||
func sortedAccountsOf(fs map[string][]Filter) []string {
|
||||
out := make([]string, 0, len(fs))
|
||||
for k := range fs {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package main
|
||||
|
||||
// What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release
|
||||
// in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same
|
||||
// profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and
|
||||
// installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the
|
||||
// system's spelling dictionaries. The tools read these files and say what they find: the version against the
|
||||
// pin and the newest published build, whether the policy is placed, Owl's version against its pin.
|
||||
//
|
||||
// **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the
|
||||
// node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh
|
||||
// placed it) and the version in the placed xpi are the pins. The community package's own copy is said as
|
||||
// such, and the switch from it waits for the operator (ADR 0316 §4).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
installDir = "/opt/betterbird"
|
||||
// policyFile is the enterprise policy the module places (ADR 0316 §7).
|
||||
policyFile = installDir + "/distribution/policies.json"
|
||||
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
|
||||
owlFile = installDir + "/owl/owl.xpi"
|
||||
owlID = "owl@beonex.com"
|
||||
// defaultClientPref is Betterbird's ask at its start to be the default mail, news, feed and calendar
|
||||
// client (Thunderbird's all-thunderbird.js: pref("mail.shell.checkDefaultClient", true); messenger.js opens
|
||||
// systemIntegrationDialog.xhtml while it is true). The module's policy locks it off (novox/hq issue 345).
|
||||
defaultClientPref = "mail.shell.checkDefaultClient"
|
||||
// preparation is the tools' program as the module places it for its start (betterbird-tools prepare).
|
||||
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
|
||||
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
|
||||
// reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with.
|
||||
versionsURL = "https://www.betterbird.eu/start/versions.txt"
|
||||
)
|
||||
|
||||
// publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers.
|
||||
var publishedBuilds = func() (string, error) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(versionsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
// Installation is what is installed, and from where.
|
||||
type Installation struct {
|
||||
// From is the mesh, the community package (betterbird-bin, before the switch), or nothing.
|
||||
From string `json:"from"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
// Newest is the newest build Betterbird published for this major version, and whether this one is behind.
|
||||
Newest string `json:"newest_published_build,omitempty"`
|
||||
Behind bool `json:"behind,omitempty"`
|
||||
// Policy says whether the module's enterprise policy is placed, and what it does.
|
||||
Policy string `json:"policy"`
|
||||
// Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it.
|
||||
Owl *OwlState `json:"owl,omitempty"`
|
||||
}
|
||||
|
||||
// OwlState is Owl against its pin.
|
||||
type OwlState struct {
|
||||
Pinned string `json:"pinned,omitempty"`
|
||||
InProfile string `json:"in_profile,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
AtThePin bool `json:"at_the_pin"`
|
||||
// Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer
|
||||
// version, and Owl installed by policy cannot be removed in Betterbird.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// newerVersion says whether version a is newer than b, comparing dotted numbers.
|
||||
func newerVersion(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(pa) || i < len(pb); i++ {
|
||||
var x, y int
|
||||
if i < len(pa) {
|
||||
fmt.Sscanf(pa[i], "%d", &x)
|
||||
}
|
||||
if i < len(pb) {
|
||||
fmt.Sscanf(pb[i], "%d", &y)
|
||||
}
|
||||
if x != y {
|
||||
return x > y
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// installation reads what is installed. profile may be empty when none is found yet.
|
||||
func (m *Machine) installation(profile string) Installation {
|
||||
var in Installation
|
||||
community, _ := m.installed(packageFor)
|
||||
raw, err := readBounded(m.path(appIni))
|
||||
switch {
|
||||
case community != "":
|
||||
in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)"
|
||||
case err == nil:
|
||||
in.From = "the mesh"
|
||||
default:
|
||||
in.From = "not installed"
|
||||
}
|
||||
if err == nil {
|
||||
_, sec := iniSections(raw)
|
||||
in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"]
|
||||
in.Newest, in.Behind = newestBuild(in.Version, in.Build)
|
||||
}
|
||||
in.Policy = m.policyState()
|
||||
pinned := xpiVersion(m.path(owlFile))
|
||||
owl := &OwlState{Pinned: pinned}
|
||||
if profile != "" {
|
||||
for _, a := range addOns(profile) {
|
||||
if a.ID == owlID {
|
||||
owl.InProfile, owl.Active = a.Version, a.Active
|
||||
}
|
||||
}
|
||||
}
|
||||
owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile
|
||||
if newerVersion(owl.InProfile, owl.Pinned) {
|
||||
owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " +
|
||||
"keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " +
|
||||
"move the module's pin in a reviewed change"
|
||||
}
|
||||
if owl.Pinned != "" || owl.InProfile != "" {
|
||||
in.Owl = owl
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// newestBuild is the newest build Betterbird published for a version's major, and whether build is older.
|
||||
func newestBuild(version, build string) (string, bool) {
|
||||
major, _, _ := strings.Cut(version, ".")
|
||||
if major == "" || build == "" {
|
||||
return "", false
|
||||
}
|
||||
list, err := publishedBuilds()
|
||||
if err != nil {
|
||||
return "unknown: " + err.Error(), false
|
||||
}
|
||||
for _, l := range strings.Split(list, "\n") {
|
||||
f := strings.Split(strings.TrimSpace(l), ",")
|
||||
if len(f) == 3 && f[0] == "Linux" && f[1] == major {
|
||||
return f[2], f[2] > build
|
||||
}
|
||||
}
|
||||
return "unknown: " + versionsURL + " names no build for " + major, false
|
||||
}
|
||||
|
||||
// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 and issue 345 ask.
|
||||
func (m *Machine) policyState() string {
|
||||
raw, err := readBounded(m.path(policyFile))
|
||||
if err != nil {
|
||||
return "not placed: " + policyFile + " is missing"
|
||||
}
|
||||
if why := policyProblem(raw); why != "" {
|
||||
return "placed, and " + why
|
||||
}
|
||||
return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " +
|
||||
"the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " +
|
||||
"and the start page locked off"
|
||||
}
|
||||
|
||||
// lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is.
|
||||
var lockedOff = []struct{ Name, What string }{
|
||||
{defaultClientPref, "the ask to be the default mail client"},
|
||||
{"mail.inappnotifications.enabled", "the vendor's in-app notices"},
|
||||
{"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"},
|
||||
}
|
||||
|
||||
// policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "".
|
||||
func policyProblem(raw []byte) string {
|
||||
var p struct {
|
||||
Policies struct {
|
||||
DisableAppUpdate bool `json:"DisableAppUpdate"`
|
||||
DisableTelemetry bool `json:"DisableTelemetry"`
|
||||
ExtensionUpdate *bool `json:"ExtensionUpdate"`
|
||||
ExtensionSettings map[string]struct {
|
||||
Mode string `json:"installation_mode"`
|
||||
URL string `json:"install_url"`
|
||||
Disabled bool `json:"updates_disabled"`
|
||||
} `json:"ExtensionSettings"`
|
||||
} `json:"policies"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return "it is not JSON: " + err.Error()
|
||||
}
|
||||
var problems []string
|
||||
if !p.Policies.DisableAppUpdate {
|
||||
problems = append(problems, "it does not turn Betterbird's updater off (DisableAppUpdate)")
|
||||
}
|
||||
if !p.Policies.DisableTelemetry {
|
||||
problems = append(problems, "it does not turn telemetry off (DisableTelemetry)")
|
||||
}
|
||||
if p.Policies.ExtensionUpdate == nil || *p.Policies.ExtensionUpdate {
|
||||
problems = append(problems, "it leaves add-ons updating themselves (ExtensionUpdate)")
|
||||
}
|
||||
owl, ok := p.Policies.ExtensionSettings[owlID]
|
||||
switch {
|
||||
case !ok || owl.Mode != "force_installed":
|
||||
problems = append(problems, "it does not install Owl into every profile (force_installed)")
|
||||
case owl.URL != "file://"+owlFile:
|
||||
problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi")
|
||||
case !owl.Disabled:
|
||||
problems = append(problems, "it leaves Owl's updates on")
|
||||
}
|
||||
prefs := policyPrefsOf(raw)
|
||||
for _, l := range lockedOff {
|
||||
if pr, ok := prefs[l.Name]; !ok || pr.Value != "false" || !pr.Locked {
|
||||
problems = append(problems, "it does not lock "+l.What+" off (Preferences "+l.Name+": false, locked)")
|
||||
}
|
||||
}
|
||||
return strings.Join(problems, "; ")
|
||||
}
|
||||
|
||||
// defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does
|
||||
// not: the placed policy's own Preferences entry locks the ask off, else the profile's user.js (which wins at
|
||||
// the start) or prefs.js turns it off. Betterbird's own default is to ask. Decided from the entry itself, never
|
||||
// from what else the policy lacks.
|
||||
func (m *Machine) defaultClientAsk(profile string) string {
|
||||
if v, ok := m.locks()[defaultClientPref]; ok && v == "false" {
|
||||
return ""
|
||||
}
|
||||
if profile != "" {
|
||||
for _, f := range []string{"user.js", "prefs.js"} {
|
||||
b, err := readBounded(filepath.Join(profile, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := statementsOf(b)[defaultClientPref]; ok {
|
||||
if v == "false" {
|
||||
return ""
|
||||
}
|
||||
return "the profile's " + f + " sets " + defaultClientPref + " to " + v + " and no policy locks it off"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "no policy locks " + defaultClientPref + " off and Betterbird's default is to ask"
|
||||
}
|
||||
|
||||
// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read.
|
||||
func xpiVersion(path string) string {
|
||||
r, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
if f.Name != "manifest.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
var mf struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil {
|
||||
return ""
|
||||
}
|
||||
return mf.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from.
|
||||
func addOns(profile string) []AddOn {
|
||||
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var ext struct {
|
||||
Addons []struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Active bool `json:"active"`
|
||||
Location string `json:"location"`
|
||||
} `json:"addons"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ext) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []AddOn
|
||||
for _, a := range ext.Addons {
|
||||
if a.Location == "app-profile" {
|
||||
out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the mesh installs, as the tools say it (novox/hq ADR 0316 §16): the version against the pin and the
|
||||
// newest published build, the policy, Owl against its pin; and the reminder test, visibly a test.
|
||||
|
||||
func (f *fake) xpi(path, version string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
out, err := os.Create(full)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(out)
|
||||
m, _ := w.Create("manifest.json")
|
||||
m.Write([]byte(`{"version": "` + version + `", "browser_specific_settings": {"gecko": {"id": "owl@beonex.com"}}}`))
|
||||
w.Close()
|
||||
out.Close()
|
||||
}
|
||||
|
||||
// placedByTheMesh is a node after the switch: the release, the policy and Owl's xpi in /opt/betterbird, and
|
||||
// the profile running Owl at the pin.
|
||||
func (f *fake) placedByTheMesh() {
|
||||
f.noPackage()
|
||||
f.write(appIni, "[App]\nVendor=Betterbird Project\nVersion=153.4.0\nBuildID=20260929222610\n")
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
f.write(policyFile, string(raw))
|
||||
f.xpi(owlFile, "1.5.6.1")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.6.1","active":true,"location":"app-profile"}]}`)
|
||||
}
|
||||
|
||||
func TestTheStatusSaysTheInstallationAgainstThePinTheNewestBuildThePolicyAndOwl(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
publishedBuilds = func() (string, error) {
|
||||
return "blocklist,{}\nLinux,153,20261101000000\nLinux,140,20260829111240\n", nil
|
||||
}
|
||||
t.Cleanup(func() { publishedBuilds = func() (string, error) { return "", os.ErrDeadlineExceeded } })
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := s.Installation
|
||||
if in.From != "the mesh" || in.Version != "153.4.0" || in.Newest != "20261101000000" || !in.Behind ||
|
||||
!strings.HasPrefix(in.Policy, "placed:") || in.Owl == nil || in.Owl.Pinned != "1.5.6.1" || !in.Owl.AtThePin || !in.Owl.Active {
|
||||
t.Fatalf("%+v %+v", in, in.Owl)
|
||||
}
|
||||
c, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, x := range c.Findings {
|
||||
found = found || strings.Contains(x.What, "behind the newest published build 20261101000000")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("a release left behind is said: %+v", c.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCommunityPackageAPolicyThatIsNotTheModulesAndAnOwlOffItsPinAreSaid(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
f.write(policyFile, `{"policies": {"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.7","active":true,"location":"app-profile"}]}`)
|
||||
in := f.installation(filepath.Join(f.Root, profileDir))
|
||||
if !strings.Contains(in.Policy, "DisableAppUpdate") || in.Owl.AtThePin || in.Owl.InProfile != "1.5.7" {
|
||||
t.Fatalf("%+v %+v", in, in.Owl)
|
||||
}
|
||||
// A newer Owl than the pin is a fact with the real remedy, never a finding to remove it.
|
||||
if !strings.Contains(in.Owl.Note, "move the module's pin") {
|
||||
t.Fatalf("%+v", in.Owl)
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`)
|
||||
c, _ := f.Check()
|
||||
for _, x := range c.Findings {
|
||||
if strings.Contains(x.What, "Owl") {
|
||||
t.Errorf("a newer Owl is not a finding: %+v", x)
|
||||
}
|
||||
}
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.5","active":true,"location":"app-profile"}]}`)
|
||||
c, _ = f.Check()
|
||||
found := false
|
||||
for _, x := range c.Findings {
|
||||
found = found || (strings.Contains(x.What, "older than the mesh's pin") && strings.Contains(x.Do, "move the module's Owl pin"))
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("an older Owl is a finding whose remedy is the pin: %+v", c.Findings)
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
|
||||
if in := f.installation(""); !strings.Contains(in.Policy, "not from the mesh's xpi") {
|
||||
t.Fatalf("%s", in.Policy)
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" && args[0] == "-Q" {
|
||||
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
in = f.installation("")
|
||||
if !strings.HasPrefix(in.From, "the community package betterbird-bin 153.1.0esr-1") || !strings.Contains(in.From, "operator") {
|
||||
t.Fatalf("%s", in.From)
|
||||
}
|
||||
raw, _ := json.Marshal(in)
|
||||
if strings.Contains(string(raw), "example.org") {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestIsVisiblyATestAndSaysItOpensAWindow(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(personal)
|
||||
a, err := f.ReminderTest(40*time.Second, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join("/run/user/1000", testUnit)
|
||||
css := f.read(dir + "/profile/chrome/userChrome.css")
|
||||
user := f.read(dir + "/profile/user.js")
|
||||
cal := f.read(dir + "/test.ics")
|
||||
if !strings.Contains(css, "MESH TEST") || !strings.Contains(css, `:root:not([windowtype="Calendar:AlarmWindow"])`) {
|
||||
t.Fatalf("the test's windows but the reminder carry a MESH TEST band: %s", css)
|
||||
}
|
||||
if !strings.Contains(user, `user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`) ||
|
||||
strings.Contains(user, "mesh_personal") || strings.Contains(user, "example.org") {
|
||||
t.Fatalf("the test profile reads its userChrome.css and holds no declared account: %s", user)
|
||||
}
|
||||
if !strings.Contains(cal, "SUMMARY:MESH TEST reminder") || !strings.Contains(a.OnScreen, "operator's screen") {
|
||||
t.Fatalf("the event and the answer say it is a test on the operator's screen: %s %+v", cal, a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit="+testUnit) || !strings.Contains(strings.Join(f.calls, "\n"), startBin+" --no-remote") {
|
||||
t.Fatalf("the test starts Betterbird itself, never the module's start: %v", f.calls)
|
||||
}
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "betterbird_reminder_test" && !strings.HasPrefix(tool.Description, "OPENS WINDOWS ON THE OPERATOR'S SCREEN") {
|
||||
t.Fatalf("the description says first that a window opens on the operator's screen: %s", tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSDescriptionSaysAdoption(t *testing.T) {
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "betterbird_user_js" && (!strings.Contains(tool.Description, "adopted under its own keys") ||
|
||||
strings.Contains(tool.Description, "refuses the apply")) {
|
||||
t.Fatalf("%s", tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Betterbird never asks at its start to be the default mail client: the module's policy locks
|
||||
// mail.shell.checkDefaultClient off, and the check says so when it is on (novox/hq issue 345).
|
||||
func TestTheDefaultClientAskIsLockedOffAndTheCheckSaysWhenItIsOn(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
asks := func() string {
|
||||
t.Helper()
|
||||
c, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, x := range c.Findings {
|
||||
if strings.Contains(x.What, "asks at its start") {
|
||||
return x.What + " | " + x.Do
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if got := asks(); got != "" {
|
||||
t.Fatalf("the module's policy locks the ask off, yet: %s", got)
|
||||
}
|
||||
unlocked := `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`
|
||||
if why := policyProblem([]byte(unlocked)); !strings.Contains(why, defaultClientPref) {
|
||||
t.Fatalf("a policy that leaves the ask on is said: %q", why)
|
||||
}
|
||||
f.write(policyFile, unlocked)
|
||||
if got := asks(); !strings.Contains(got, "default mail client") || !strings.Contains(got, "push the module") {
|
||||
t.Fatalf("the ask on is a finding with its remedy: %q", got)
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "default"}},
|
||||
"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", "install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`)
|
||||
if why := policyProblem(mustRead(t, f, policyFile)); !strings.Contains(why, "locked") {
|
||||
t.Fatalf("a default the profile can override is not a lock: %q", why)
|
||||
}
|
||||
f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", true);`+"\n")
|
||||
if got := asks(); got == "" {
|
||||
t.Fatal("a profile that turned the ask back on over an unlocked default is a finding")
|
||||
}
|
||||
f.write(policyFile, unlocked)
|
||||
f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", false);`+"\n")
|
||||
if got := asks(); got != "" {
|
||||
t.Fatalf("a profile with the ask off does not ask: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, f *fake, p string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(f.Root, p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
package main
|
||||
|
||||
// What the tools show of the five kinds (novox/hq ADR 0329 §1, §11–§13): the profile's entries beside the declared
|
||||
// ones, with the operator's own values, and how a node differs from what is declared. Never a credential (§2):
|
||||
// every answer passes credentialChecked, which withholds a field whose name says password, token, secret or cookie
|
||||
// and the password part of any address.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// configView is the profile and the settings, read once for an answer.
|
||||
type configView struct {
|
||||
p Profile
|
||||
prefs map[string]string
|
||||
accounts map[string]Account
|
||||
ra []resolvedAccount
|
||||
conf Configuration
|
||||
problem string
|
||||
plan configPlan
|
||||
rec configRecord
|
||||
}
|
||||
|
||||
func (m *Machine) view() (configView, error) {
|
||||
var v configView
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.p, v.prefs = p, mergedPrefs(p.path)
|
||||
accounts, _, aerr := m.readAccounts()
|
||||
if aerr != nil {
|
||||
v.problem = aerr.Error()
|
||||
}
|
||||
v.accounts = accounts
|
||||
v.ra = resolveAccounts(accounts, v.prefs, m.readRecord(p.Dir))
|
||||
conf, cerr := m.readConfiguration()
|
||||
if cerr != nil {
|
||||
v.problem = strings.TrimPrefix(v.problem+"; "+cerr.Error(), "; ")
|
||||
}
|
||||
v.conf = conf
|
||||
v.rec = m.readConfigRecord(p.Dir)
|
||||
v.plan = m.planConfiguration(p, conf, accounts, v.ra, v.prefs)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// listedEntry is one entry of the profile, with its values: Owl's marked, an ICS calendar by its name only (§13).
|
||||
type listedEntry struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// Account is an identity's account key in the profile, or the account name filters are listed under.
|
||||
Account string `json:"account,omitempty"`
|
||||
Owl bool `json:"owl,omitempty"`
|
||||
// DeclaredAs is the declared entry this one is, or empty: the person's own.
|
||||
DeclaredAs string `json:"declared_as,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// declaredEntry is one declared entry, its value and how it resolved on this node.
|
||||
type declaredEntry struct {
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
How string `json:"how,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// KindListing is what a listing tool answers.
|
||||
type KindListing struct {
|
||||
Profile string `json:"profile"`
|
||||
Kind string `json:"kind"`
|
||||
InProfile []listedEntry `json:"in_the_profile"`
|
||||
Declared []declaredEntry `json:"declared"`
|
||||
// Kept says where the module keeps the filter files it replaced, per account (§8).
|
||||
Kept map[string]string `json:"kept_filter_files,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
func (v configView) resolved(kind string) map[string]resolvedEntry {
|
||||
out := map[string]resolvedEntry{}
|
||||
for _, e := range v.plan.Entries {
|
||||
if e.Kind == kind {
|
||||
out[e.Name] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaredBy is, per profile key, the declared entry of a kind adopted or created under it.
|
||||
func (v configView) declaredBy(kind string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, e := range v.plan.Entries {
|
||||
if e.Kind == kind && e.How != refused {
|
||||
out[e.Key] = e.Name
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List answers one kind: the profile's entries beside the declared ones.
|
||||
func (m *Machine) List(kind string) (KindListing, error) {
|
||||
v, err := m.view()
|
||||
a := KindListing{Kind: kind, InProfile: []listedEntry{}, Declared: []declaredEntry{}}
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Profile, a.Problem = v.p.Dir, v.problem
|
||||
res := v.resolved(kind)
|
||||
by := v.declaredBy(kind)
|
||||
declare := func(name string, value any) {
|
||||
r := res[name]
|
||||
a.Declared = append(a.Declared, declaredEntry{Name: name, Value: value, How: r.How, Key: r.Key, Why: r.Why})
|
||||
}
|
||||
switch kind {
|
||||
case kindIdentities:
|
||||
for _, pi := range m.profileIdentities(v.prefs) {
|
||||
a.InProfile = append(a.InProfile, listedEntry{Key: pi.Key, Account: pi.AccountKey, Owl: pi.Owl,
|
||||
DeclaredAs: by[pi.Key], Value: pi.Value})
|
||||
}
|
||||
for _, name := range sortedKeys(v.conf.Identities) {
|
||||
declare(name, v.conf.Identities[name])
|
||||
}
|
||||
case kindCalendars:
|
||||
for _, pc := range profileCalendars(v.prefs) {
|
||||
e := listedEntry{Key: pc.Key, Kind: pc.Kind, DeclaredAs: by[pc.Key], Value: pc.Value}
|
||||
switch {
|
||||
case strings.HasPrefix(pc.Kind, "owl"):
|
||||
e.Owl, e.Value = true, map[string]string{"name": pc.Value.Name, "url": pc.Value.URL}
|
||||
e.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
|
||||
case pc.Kind == "ics":
|
||||
e.Value = map[string]string{"name": pc.Value.Name}
|
||||
e.Note = "subscribed by an ICS address, which is often itself the secret: its name only, never declared (novox/hq ADR 0329 §13)"
|
||||
case pc.Kind != "caldav":
|
||||
e.Note = "kept in the profile: its events are never declared"
|
||||
}
|
||||
a.InProfile = append(a.InProfile, e)
|
||||
}
|
||||
for _, name := range sortedKeys(v.conf.Calendars) {
|
||||
declare(name, v.conf.Calendars[name])
|
||||
}
|
||||
case kindAddressBooks:
|
||||
for _, b := range profileAddressBooks(v.prefs) {
|
||||
e := listedEntry{Key: b.Key, Kind: b.Kind, DeclaredAs: by[b.Key], Value: b.Value}
|
||||
if !b.CardDAV {
|
||||
e.Value, e.Note = map[string]string{"name": b.Value.Name}, "its contacts are kept in the profile and never declared"
|
||||
}
|
||||
a.InProfile = append(a.InProfile, e)
|
||||
}
|
||||
for _, name := range sortedKeys(v.conf.AddressBooks) {
|
||||
declare(name, v.conf.AddressBooks[name])
|
||||
}
|
||||
case kindTags:
|
||||
for _, t := range profileTags(v.prefs) {
|
||||
a.InProfile = append(a.InProfile, listedEntry{Key: t.Key, DeclaredAs: by[t.Key], Value: t.Value})
|
||||
}
|
||||
for _, key := range sortedKeys(v.conf.Tags) {
|
||||
declare(key, v.conf.Tags[key])
|
||||
}
|
||||
case kindFilters:
|
||||
for _, f := range m.profileFilters(v) {
|
||||
a.InProfile = append(a.InProfile, f)
|
||||
}
|
||||
for _, account := range v.conf.sortedFilterAccounts() {
|
||||
d := declaredEntry{Name: account, Value: v.conf.Filters[account], How: "owned whole"}
|
||||
for _, fp := range v.plan.filters {
|
||||
if fp.Account == account {
|
||||
d.Key, d.Why = fp.File, strings.TrimSpace(fp.Refused+" "+fp.Waiting)
|
||||
if fp.Refused != "" {
|
||||
d.How = refused
|
||||
}
|
||||
}
|
||||
}
|
||||
a.Declared = append(a.Declared, d)
|
||||
}
|
||||
a.Kept = map[string]string{}
|
||||
for _, account := range sortedKeys(v.rec.Filters) {
|
||||
if r := v.rec.Filters[account]; r.Kept != "" {
|
||||
a.Kept[account] = r.Kept
|
||||
}
|
||||
}
|
||||
if len(a.Kept) == 0 {
|
||||
a.Kept = nil
|
||||
}
|
||||
default:
|
||||
return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", "))
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// profileFilters are the filters of each declared account in the profile, by the account's name.
|
||||
func (m *Machine) profileFilters(v configView) []listedEntry {
|
||||
var out []listedEntry
|
||||
for _, r := range v.ra {
|
||||
if r.How == refused {
|
||||
continue
|
||||
}
|
||||
dir := m.accountDirectory(v.p.path, v.prefs, r.Keys.Server)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
text, err := readBounded(dir + "/" + filterFileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parsed := parseFilters(string(text), folderBase(v.prefs, r.Keys.Server))
|
||||
e := listedEntry{Key: m.tilde(strings.TrimPrefix(dir+"/"+filterFileName, m.Root)), Account: r.name, Value: parsed.Filters}
|
||||
if _, declared := v.conf.Filters[r.name]; declared {
|
||||
e.DeclaredAs = r.name
|
||||
}
|
||||
if len(parsed.NotDeclarable) > 0 {
|
||||
e.Note = "not declarable as they are: " + strings.Join(parsed.NotDeclarable, "; ")
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fieldDiff is one field a profile holds differently from the declared value.
|
||||
type fieldDiff struct {
|
||||
Field string `json:"field"`
|
||||
Declared any `json:"declared"`
|
||||
Profile any `json:"profile"`
|
||||
}
|
||||
|
||||
// DriftEntry is one declared entry against the profile: holds, differs, lacks or refused (§11).
|
||||
type DriftEntry struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Differs []fieldDiff `json:"differs,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// KindDrift is one kind's drift, and the profile's entries nothing declares.
|
||||
type KindDrift struct {
|
||||
Kind string `json:"kind"`
|
||||
Declared []DriftEntry `json:"declared"`
|
||||
Undeclared []listedEntry `json:"undeclared,omitempty"`
|
||||
}
|
||||
|
||||
// DriftAnswer is what betterbird_drift answers.
|
||||
type DriftAnswer struct {
|
||||
Profile string `json:"profile"`
|
||||
Kinds []KindDrift `json:"kinds"`
|
||||
Note string `json:"note"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
// Drift answers, per kind, how the profile differs from what is declared: the profile as Betterbird reads it at
|
||||
// its next start (prefs.js with user.js over it), so a declared value the module placed and Betterbird has not
|
||||
// started with yet holds; the drift then is only what the module did not place.
|
||||
func (m *Machine) Drift(kind string) (DriftAnswer, error) {
|
||||
v, err := m.view()
|
||||
a := DriftAnswer{Kinds: []KindDrift{}}
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Profile, a.Problem = v.p.Dir, v.problem
|
||||
a.Note = "the profile as Betterbird reads it at its next start (prefs.js, the module's user.js over it): differs " +
|
||||
"names each field with the declared value and the profile's; lacks is an entry the profile does not hold yet " +
|
||||
"(betterbird_user_js with apply, betterbird_restart); undeclared entries stay the person's"
|
||||
kinds := allKinds
|
||||
if kind != "" {
|
||||
if !contains(allKinds, kind) {
|
||||
return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", "))
|
||||
}
|
||||
kinds = []string{kind}
|
||||
}
|
||||
for _, k := range kinds {
|
||||
a.Kinds = append(a.Kinds, m.kindDrift(v, k))
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (m *Machine) kindDrift(v configView, kind string) KindDrift {
|
||||
d := KindDrift{Kind: kind, Declared: []DriftEntry{}}
|
||||
res := v.resolved(kind)
|
||||
by := v.declaredBy(kind)
|
||||
entry := func(name string, declared any, profile func(key string) (any, bool)) {
|
||||
r := res[name]
|
||||
e := DriftEntry{Name: name, Key: r.Key}
|
||||
switch {
|
||||
case r.How == refused:
|
||||
e.State, e.Why = refused, r.Why
|
||||
default:
|
||||
have, ok := profile(r.Key)
|
||||
if !ok {
|
||||
e.State = "lacks"
|
||||
break
|
||||
}
|
||||
e.Differs = differing(declared, have)
|
||||
e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0]
|
||||
}
|
||||
d.Declared = append(d.Declared, e)
|
||||
}
|
||||
switch kind {
|
||||
case kindIdentities:
|
||||
ids := map[string]profileIdentity{}
|
||||
for _, pi := range m.profileIdentities(v.prefs) {
|
||||
ids[pi.Key] = pi
|
||||
if by[pi.Key] == "" && !pi.Owl {
|
||||
d.Undeclared = append(d.Undeclared, listedEntry{Key: pi.Key, Account: pi.AccountKey, Value: pi.Value})
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(v.conf.Identities) {
|
||||
want := *v.conf.Identities[name]
|
||||
want.Key = ""
|
||||
entry(name, want, func(key string) (any, bool) {
|
||||
pi, ok := ids[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
have := pi.Value
|
||||
have.Account = want.Account
|
||||
if want.Address == "" {
|
||||
have.Address, have.Name = "", ""
|
||||
}
|
||||
return have, true
|
||||
})
|
||||
}
|
||||
case kindCalendars:
|
||||
cals := map[string]profileCalendar{}
|
||||
for _, pc := range profileCalendars(v.prefs) {
|
||||
cals[pc.Key] = pc
|
||||
if by[pc.Key] == "" && pc.Kind == "caldav" {
|
||||
d.Undeclared = append(d.Undeclared, listedEntry{Key: pc.Key, Kind: pc.Kind, Value: pc.Value})
|
||||
}
|
||||
}
|
||||
idName := v.declaredBy(kindIdentities)
|
||||
for _, name := range sortedKeys(v.conf.Calendars) {
|
||||
want := *v.conf.Calendars[name]
|
||||
want.Key = ""
|
||||
entry(name, want, func(key string) (any, bool) {
|
||||
pc, ok := cals[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
have := pc.Value
|
||||
have.Invitations = idName[pc.IdentityKey]
|
||||
if have.Reminders == nil && want.Reminders != nil && *want.Reminders {
|
||||
on := true
|
||||
have.Reminders = &on
|
||||
}
|
||||
return have, true
|
||||
})
|
||||
}
|
||||
case kindAddressBooks:
|
||||
books := map[string]profileAddressBook{}
|
||||
for _, b := range profileAddressBooks(v.prefs) {
|
||||
books[b.Key] = b
|
||||
if by[b.Key] == "" && b.CardDAV {
|
||||
d.Undeclared = append(d.Undeclared, listedEntry{Key: b.Key, Kind: b.Kind, Value: b.Value})
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(v.conf.AddressBooks) {
|
||||
want := *v.conf.AddressBooks[name]
|
||||
want.Key = ""
|
||||
entry(name, want, func(key string) (any, bool) {
|
||||
b, ok := books[key]
|
||||
return b.Value, ok
|
||||
})
|
||||
}
|
||||
case kindTags:
|
||||
tags := map[string]profileTag{}
|
||||
for _, t := range profileTags(v.prefs) {
|
||||
tags[t.Key] = t
|
||||
if by[t.Key] == "" {
|
||||
d.Undeclared = append(d.Undeclared, listedEntry{Key: t.Key, Value: t.Value})
|
||||
}
|
||||
}
|
||||
for _, key := range sortedKeys(v.conf.Tags) {
|
||||
want := *v.conf.Tags[key]
|
||||
entry(key, want, func(k string) (any, bool) {
|
||||
t, ok := tags[k]
|
||||
have := t.Value
|
||||
if want.Order == 0 {
|
||||
have.Order = 0
|
||||
}
|
||||
return have, ok
|
||||
})
|
||||
}
|
||||
case kindFilters:
|
||||
files := map[string]listedEntry{}
|
||||
for _, f := range m.profileFilters(v) {
|
||||
files[f.Account] = f
|
||||
released := v.rec.Filters[f.Account].Released
|
||||
if f.DeclaredAs == "" && !released {
|
||||
if list, _ := f.Value.([]Filter); len(list) > 0 {
|
||||
d.Undeclared = append(d.Undeclared, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, fp := range v.plan.filters {
|
||||
e := DriftEntry{Name: fp.Account, Key: fp.File}
|
||||
switch {
|
||||
case fp.Refused != "":
|
||||
e.State, e.Why = refused, fp.Refused
|
||||
case fp.path == "" || !fp.exists:
|
||||
e.State, e.Why = "lacks", strings.TrimSpace(fp.Waiting)
|
||||
default:
|
||||
have, _ := files[fp.Account].Value.([]Filter)
|
||||
for _, line := range filterDifferences(v.conf.Filters[fp.Account], have) {
|
||||
e.Differs = append(e.Differs, fieldDiff{Field: line})
|
||||
}
|
||||
if fp.have != fp.want && len(e.Differs) == 0 {
|
||||
e.Differs = append(e.Differs, fieldDiff{Field: "the file's text, beyond the filters' own words"})
|
||||
}
|
||||
e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0]
|
||||
}
|
||||
d.Declared = append(d.Declared, e)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// differing are the fields of two values of the same shape that differ.
|
||||
func differing(declared, have any) []fieldDiff {
|
||||
var a, b map[string]any
|
||||
_ = json.Unmarshal([]byte(mustJSON(declared)), &a)
|
||||
_ = json.Unmarshal([]byte(mustJSON(have)), &b)
|
||||
var out []fieldDiff
|
||||
for _, f := range sortedKeys(mergeKeys(a, b)) {
|
||||
if mustJSON(a[f]) != mustJSON(b[f]) {
|
||||
out = append(out, fieldDiff{Field: f, Declared: a[f], Profile: b[f]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// driftCounts are, per kind, how many declared entries do not hold.
|
||||
func (m *Machine) driftCounts(v configView) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, k := range allKinds {
|
||||
for _, e := range m.kindDrift(v, k).Declared {
|
||||
if e.State != "holds" {
|
||||
out[k]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// credentialChecked is an answer as it may leave the tool (§2): a field whose name says password, token, secret
|
||||
// or cookie withheld, and the password part of any address as ‹withheld›.
|
||||
func credentialChecked(v any) any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
var tree any
|
||||
if json.Unmarshal(b, &tree) != nil {
|
||||
return v
|
||||
}
|
||||
return scrubTree(tree)
|
||||
}
|
||||
|
||||
func scrubTree(n any) any {
|
||||
switch x := n.(type) {
|
||||
case map[string]any:
|
||||
for k, v := range x {
|
||||
if secretName.MatchString(k) {
|
||||
x[k] = "‹withheld›"
|
||||
continue
|
||||
}
|
||||
x[k] = scrubTree(v)
|
||||
}
|
||||
return x
|
||||
case []any:
|
||||
for i, v := range x {
|
||||
x[i] = scrubTree(v)
|
||||
}
|
||||
return x
|
||||
case string:
|
||||
return withoutPassword(x)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// The betterbird module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): Betterbird, the
|
||||
// mail and calendar client, in the operator's session, served by the node's runtime as the operator
|
||||
// account. The module holds no seat, so every tool is its own.
|
||||
//
|
||||
// The operator's mail, calendar events, contacts and credentials are never read: a tool reads processes,
|
||||
// profiles.ini, and of the profile its prefs.js, user.js, extensions.json, each account's msgFilterRules.dat and
|
||||
// of logins.json only the server each kept sign-in is for. The tools show the operator's own configuration, an
|
||||
// account's, an identity's, a calendar's, an address book's, a filter's and a tag's values, and never a credential
|
||||
// (novox/hq ADR 0329 §1, §2): every answer passes credentialChecked.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The module's start runs this before Betterbird (novox/hq ADR 0316 §11, §12): the user.js placed and the
|
||||
// declared accounts written into the account lists, while no Betterbird of this account runs.
|
||||
if len(os.Args) == 2 && os.Args[1] == "prepare" {
|
||||
if err := machine.Prepare(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "betterbird: "+err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
list := toolList()
|
||||
for i := range list {
|
||||
run := list[i].Run
|
||||
list[i].Run = func(args map[string]any) (any, error) {
|
||||
out, err := run(args)
|
||||
return credentialChecked(out), err
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// listTool is the listing tool of one of the five kinds (novox/hq ADR 0329 §12).
|
||||
func listTool(kind, what string) stdio.Tool {
|
||||
return stdio.Tool{
|
||||
Name: "betterbird_" + strings.ReplaceAll(kind, "-", "_"),
|
||||
Description: "The " + what + " of the profile Betterbird uses beside the ones the module's setting " + kind +
|
||||
" declares (novox/hq ADR 0329): each of the profile's with its key, its values and the declared entry it is " +
|
||||
"(none: the person's own), Owl's marked; each declared one with its value and how it resolved on this node " +
|
||||
"(adopted, created or refused with why, and its key). The operator's own values, never a password, token " +
|
||||
"or cookie. betterbird_drift says how they differ. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.List(kind) },
|
||||
}
|
||||
}
|
||||
|
||||
func toolList() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "betterbird_status",
|
||||
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
|
||||
"many child processes); what is installed in /opt/betterbird and from where (the mesh's pinned release, " +
|
||||
"or the community package before the switch), its version and build beside the newest published build; " +
|
||||
"whether the enterprise policy is placed; Owl's version against the mesh's pin and whether it is active; " +
|
||||
"where its output goes; the profile it uses and how that was found; the module's user.js (declared, " +
|
||||
"placed, in effect, with every declared identity, calendar, address book and tag as it resolved); the " +
|
||||
"declared accounts by count (in the profile's list, other accounts, not signed in yet); the add-ons in the " +
|
||||
"profile; and the preferences that decide how its windows and notifications look. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "betterbird_prefs",
|
||||
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (the " +
|
||||
"module's policy, which locks it; user.js, which wins at the start; prefs.js; or the default), and the " +
|
||||
"default itself with the file that gives it: Betterbird's own, read from omni.ja and the installation's " +
|
||||
"defaults/pref, or the module's defaults (mesh-defaults.js). A name ending in . or * is a prefix (at most " +
|
||||
"200 answered). A preference whose name can hold a credential, or that names an account, a server, a " +
|
||||
"calendar or a person, is answered without its value. (r)",
|
||||
Input: map[string]any{
|
||||
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
names, err := texts(args, "names")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Prefs(names)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_config",
|
||||
Description: "Betterbird's preferences through the module's layers, preference by preference: the value its " +
|
||||
"policy locks, the setting prefs (the operator's setup, mesh-wide with this node's layer over it, one entry " +
|
||||
"per preference), Betterbird's own default where the module keeps that, the profile's own saved value and " +
|
||||
"the value in effect. It flags drift: a declared value the profile's user.js does not hold yet, or a value " +
|
||||
"the profile saved where the module keeps Betterbird's default. With keep (names), it proposes this node's " +
|
||||
"layer of the setting prefs holding the profile's values of those preferences beside what the layer holds, " +
|
||||
"through the controller's settings verb, for the operator's Approve; it never writes user.js. (r; a with keep)",
|
||||
Input: map[string]any{
|
||||
"keep": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "managed preferences whose profile value this machine keeps: proposed as a node layer, e.g. [\"mail.uifontsize\"]"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
keep, err := texts(args, "keep")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Config(keep)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_config_apply",
|
||||
Description: "Bring the profile back to the module's configuration: takes the drifting values " +
|
||||
"(betterbird_config) out of the profile's prefs.js, so the module's defaults hold at Betterbird's next " +
|
||||
"start, and says what it cleared, each with the value it had and the one that now holds. Without apply " +
|
||||
"it lists them only. With apply it acts only while Betterbird is not running, because Betterbird " +
|
||||
"rewrites prefs.js when it quits; every other line of prefs.js is kept. (r; a with apply)",
|
||||
Input: map[string]any{
|
||||
"apply": map[string]any{"type": "boolean", "description": "clear the drifting values (default false: a dry run)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
apply, err := flag(args, "apply", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.ConfigApply(apply)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_user_js",
|
||||
Description: "The module's user.js — the setting prefs, then the declared accounts' preferences (setting " +
|
||||
"accounts), then the declared identities, calendars, address books and tags — against the profile's " +
|
||||
"user.js: whether it is placed, and whether the running Betterbird started after it was. With apply, place " +
|
||||
"it in the profile (only where the profile has no user.js or the module's own; an operator's file is never " +
|
||||
"overwritten). A declared entry the profile already holds is adopted under its own keys; one it does not " +
|
||||
"hold is created under the module's keys; an ambiguous one is refused alone, with the remedy (novox/hq ADR " +
|
||||
"0317, ADR 0329 §7). What Betterbird rewrites itself — the account and identity lists, the calendars' " +
|
||||
"order, the removals and each declared account's msgFilterRules.dat — is written only while Betterbird is " +
|
||||
"closed, and the answer counts what waits (§8, §9). Accounts' values are withheld in the user.js shown. " +
|
||||
"Betterbird reads it at its next start: betterbird_restart. (r; a with apply)",
|
||||
Input: map[string]any{
|
||||
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
apply, err := flag(args, "apply", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.userJS(apply)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_log",
|
||||
Description: "The last lines Betterbird printed, from the journal of the unit the module starts it as " +
|
||||
"(mesh-betterbird; or mesh-betterbird-reminder-test). With problems, only lines that say error, " +
|
||||
"warning, assertion, crash or failure. Mail addresses and credentials are replaced by <hidden>. A " +
|
||||
"Betterbird the module did not start prints elsewhere, and the answer says so. (r)",
|
||||
Input: map[string]any{
|
||||
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
|
||||
"problems": map[string]any{"type": "boolean", "description": "only problem lines (default false)"},
|
||||
"unit": map[string]any{"type": "string", "enum": []string{restartAs, testUnit}, "description": "which start's journal (default " + restartAs + ")"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "lines", 100, 1, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
problems, err := flag(args, "problems", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unit, err := text(args, "unit", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Log(n, problems, unit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_restart",
|
||||
Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " +
|
||||
"allows it, and start Betterbird again in the operator's desktop session under the account's service " +
|
||||
"manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " +
|
||||
"(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " +
|
||||
"ask the operator first. Answers the pids ended and the new main process. (a)",
|
||||
Input: map[string]any{
|
||||
"discard_unsent": map[string]any{"type": "boolean", "description": "restart even with a composer open, losing what it holds (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
discard, err := flag(args, "discard_unsent", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Restart(discard)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_check",
|
||||
Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " +
|
||||
"behind the newest published build; the enterprise policy placed and the module's defaults with it; no " +
|
||||
"managed preference drifting from them; Owl at its pin and active; one " +
|
||||
"Betterbird runs; its profile is found; the module's user.js on the machine, valid, placed and in " +
|
||||
"effect; the declared accounts in the profile's list, none refused, each signed in once; per kind, no " +
|
||||
"declared identity, calendar, address book, filter or tag drifting or refused, and no change waiting for " +
|
||||
"Betterbird to close; its output read. Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
{
|
||||
Name: "betterbird_accounts",
|
||||
Description: "The accounts of the profile Betterbird uses with their values (name, address, user, servers' " +
|
||||
"hosts and ports) and the declared account each is, Owl's marked by name and address (never declared), " +
|
||||
"with counts by kind. Never a password, a token or anything of the login store (novox/hq ADR 0329 §1, §2). " +
|
||||
"With propose, it builds the module's setting accounts from them (every account but Owl's) and proposes " +
|
||||
"it in the mesh-wide layer as it stands, through the controller's settings verb, for the operator's " +
|
||||
"Approve on a channel that proves who answers (novox/hq ADR 0317, ADR 0277); the answer shows the value it " +
|
||||
"proposed. (r; a with propose)",
|
||||
Input: map[string]any{
|
||||
"propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
propose, err := flag(args, "propose", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.ProfileAccounts(propose)
|
||||
},
|
||||
},
|
||||
listTool(kindIdentities, "identities (each account's default and its other identities, with name, address, reply-to, organisation and signature)"),
|
||||
listTool(kindCalendars, "calendars (CalDAV with name, address, colour and options; an ICS one by its name only)"),
|
||||
listTool(kindAddressBooks, "address books (CardDAV with name, address and user; a local one by its name)"),
|
||||
listTool(kindFilters, "filters (per declared account: its msgFilterRules.dat as filters, folders by their path within the account; where the module keeps a file it replaced)"),
|
||||
listTool(kindTags, "tags (key, name, colour, place)"),
|
||||
{
|
||||
Name: "betterbird_derive",
|
||||
Description: "Derive the module's settings identities, calendars, address-books, filters and tags (or the kinds " +
|
||||
"given) from the profiles of every desktop node holding Betterbird, laid out per layer: an entry on every " +
|
||||
"such node goes into the mesh-wide layer with this node's value, and the answer lists each field another " +
|
||||
"node holds differently; one on some nodes only goes into those nodes' own layers. Owl's, ICS calendars, " +
|
||||
"local address books and any address carrying a password are left out and named. prefs puts values of the " +
|
||||
"setting prefs into the mesh-wide layer. With propose, the next layer (the mesh-wide first, then each " +
|
||||
"node's) is proposed for the operator's Approve through the controller's settings verb, carrying the layer " +
|
||||
"as it stands beside the derived kinds; refused while a proposal of the module's waits, or while a desktop " +
|
||||
"node does not answer (novox/hq ADR 0329 §10, ADR 0277). here answers this node's profile only, as the " +
|
||||
"other nodes are asked. The values are the operator's own; never a credential. (r; a with propose)",
|
||||
Input: map[string]any{
|
||||
"kinds": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": allKinds},
|
||||
"description": "the kinds to derive (default all five)"},
|
||||
"here": map[string]any{"type": "boolean", "description": "this node's profile only (default false)"},
|
||||
"propose": map[string]any{"type": "boolean", "description": "propose the next layer to the operator (default false)"},
|
||||
"prefs": map[string]any{"type": "object", "description": "values of the setting prefs for the mesh-wide " +
|
||||
"layer, by preference name, e.g. {\"mail.uifontsize\": 14}"},
|
||||
"layer": map[string]any{"type": "string", "description": "the layer to propose instead of the next: mesh-wide or a node's name"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
kinds, err := texts(args, "kinds")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
here, err := flag(args, "here", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
propose, err := flag(args, "propose", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layer, err := text(args, "layer", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prefs map[string]any
|
||||
if v, ok := args["prefs"]; ok && v != nil {
|
||||
if prefs, ok = v.(map[string]any); !ok {
|
||||
return nil, fmt.Errorf("prefs is an object of preference names and values, not %T", v)
|
||||
}
|
||||
}
|
||||
return machine.Derive(kinds, here, propose, prefs, layer)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_drift",
|
||||
Description: "How this node's profile differs from the declared identities, calendars, address books, " +
|
||||
"filters and tags (novox/hq ADR 0329 §11): per declared entry whether the profile holds it, holds another " +
|
||||
"value (each differing field with the declared value and the profile's), lacks it, or it is refused; and " +
|
||||
"the profile's entries nothing declares, which stay the person's. The profile as Betterbird reads it at " +
|
||||
"its next start. (r)",
|
||||
Input: map[string]any{
|
||||
"kind": map[string]any{"type": "string", "enum": allKinds, "description": "one kind (default every kind)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
kind, err := text(args, "kind", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Drift(kind)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_reminder_test",
|
||||
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
|
||||
"reminder without touching the operator's calendar: a second Betterbird in a throw-away profile under " +
|
||||
"the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most 10 minutes), its main " +
|
||||
"window marked MESH TEST in red, with the module's user.js (no accounts) and one local calendar file " +
|
||||
"holding one event, \"MESH TEST reminder\", whose reminder is due after `after` seconds. carry copies " +
|
||||
"named preferences of the operator's profile (a theme, a colour switch) into it, never a credential or " +
|
||||
"an account. Then xorg_windows (class Calendar) and xorg_screenshot show the reminder window. stop ends " +
|
||||
"it and removes the profile. (a)",
|
||||
Input: map[string]any{
|
||||
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
|
||||
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "preference names to copy from the operator's profile, e.g. [\"extensions.activeThemeID\"]"},
|
||||
"stop": map[string]any{"type": "boolean", "description": "end the test Betterbird and remove its profile (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
after, err := whole(args, "after", 40, 15, 300)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
carry, err := texts(args, "carry")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stop, err := flag(args, "stop", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.ReminderTest(time.Duration(after)*time.Second, carry, stop)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory
|
||||
// in the home with the mergeable files the settings fill, each naming the keys it takes (ADR 0326): prefs, accounts
|
||||
// and configuration (identities, calendars, address-books, filters) trusted, tags not (ADR 0329 §5); the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
|
||||
// its pinned upstream release, Owl pinned by its vendor's digest, the policy and the dictionaries' default
|
||||
// unpacked into the installation; the tools' program for the start's preparation, the start and the launcher;
|
||||
// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Upgrade map[string]string `json:"upgrade"`
|
||||
Requires []string `json:"requires"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Config map[string]any `json:"config"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("module.json has no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifact(t *testing.T, m manifest, name string) map[string]any {
|
||||
t.Helper()
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == name {
|
||||
return a
|
||||
}
|
||||
}
|
||||
t.Fatalf("module.json builds no artifact %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
// Every new build waits for a person's push: the switch removes a package and replaces what a running
|
||||
// Betterbird has open (ADR 0316 §4).
|
||||
if m.Upgrade["policy"] != "record" || strings.TrimSpace(m.Upgrade["why"]) == "" {
|
||||
t.Fatalf("the module is marked for a person's approval, with why: %v", m.Upgrade)
|
||||
}
|
||||
var ids []string
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
want := "configuration-dir,prefs,accounts,configuration,tags,community-package,installation,owl,policies,spelling,preparation,start,launcher"
|
||||
if strings.Join(ids, ",") != want {
|
||||
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
|
||||
}
|
||||
pkg := resource(t, m, "community-package")
|
||||
if pkg["type"] != "package" || pkg["package"] != packageFor || pkg["absent"] != true {
|
||||
t.Fatalf("the community package is declared absent (ADR 0180): %v", pkg)
|
||||
}
|
||||
places := map[string]string{"installation": installDir, "owl": filepath.Dir(owlFile),
|
||||
"policies": filepath.Dir(policyFile), "spelling": installDir + "/defaults/pref", "preparation": filepath.Dir(preparation)}
|
||||
arts := map[string]string{"installation": "archive", "owl": "owl", "policies": "distribution", "spelling": "defaults", "preparation": "tools"}
|
||||
for id, path := range places {
|
||||
r := resource(t, m, id)
|
||||
if r["type"] != "archive" || r["path"] != path || r["artifact"] != arts[id] || r["owner"] != nil {
|
||||
t.Errorf("%s is the %s archive unpacked at %s, root's: %v", id, arts[id], path, r)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
||||
if regexp.MustCompile(regexp.QuoteMeta(never) + `\b`).MatchString(raw) {
|
||||
t.Errorf("module.json names %q: the profile is Betterbird's", never)
|
||||
}
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
|
||||
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
|
||||
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
|
||||
// operator's setup still runs Betterbird; and each file's default declares nothing.
|
||||
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
||||
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
||||
}
|
||||
for key, raw := range m.Config {
|
||||
d, _ := raw.(map[string]any)
|
||||
def, _ := d["default"].(map[string]any)
|
||||
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
|
||||
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
|
||||
}
|
||||
}
|
||||
files := map[string]struct {
|
||||
path, mode string
|
||||
takes []any
|
||||
trusted bool
|
||||
}{
|
||||
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
|
||||
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
|
||||
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
|
||||
"tags": {tagsFile, "0644", []any{"tags"}, true},
|
||||
}
|
||||
merging := 0
|
||||
for _, r := range m.Resources {
|
||||
if r["merge"] != nil {
|
||||
merging++
|
||||
}
|
||||
}
|
||||
if merging != len(files) {
|
||||
t.Fatalf("%d mergeable files, want %d", merging, len(files))
|
||||
}
|
||||
for id, want := range files {
|
||||
r := resource(t, m, id)
|
||||
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
|
||||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
|
||||
t.Errorf("%s: %v", id, r)
|
||||
}
|
||||
var content map[string]any
|
||||
if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) {
|
||||
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
||||
}
|
||||
}
|
||||
got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("its default declares no account: %v %v", got, err)
|
||||
}
|
||||
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
||||
if err != nil || len(prefs) != 0 {
|
||||
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
||||
}
|
||||
var c Configuration
|
||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothPinsAreAURLAVersionAndASha256AndOwlIsNeverInTheRepository(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
sha := regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
for _, name := range []string{"archive", "owl"} {
|
||||
a := artifact(t, m, name)
|
||||
u, _ := a["url"].(string)
|
||||
if a["kind"] != "archive" || !strings.HasPrefix(u, "https://") || !sha.MatchString(fmt.Sprint(a["sha256"])) ||
|
||||
strings.TrimSpace(fmt.Sprint(a["version"])) == "" || a["from"] != nil {
|
||||
t.Errorf("%s is pinned by URL, version and sha256, and copied from nowhere in the repository: %v", name, a)
|
||||
}
|
||||
}
|
||||
if a := artifact(t, m, "archive"); a["within"] != "betterbird" {
|
||||
t.Errorf("the release unpacks into betterbird/, whose content is the installation: %v", a)
|
||||
}
|
||||
if a := artifact(t, m, "owl"); a["file"] != filepath.Base(owlFile) {
|
||||
t.Errorf("Owl's xpi is kept whole as %s: %v", filepath.Base(owlFile), a)
|
||||
}
|
||||
// Owl's licence forbids copying it to third parties: no xpi anywhere in the module's tree.
|
||||
filepath.Walk(filepath.Join("..", ".."), func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && strings.HasSuffix(strings.ToLower(p), ".xpi") {
|
||||
t.Errorf("%s is in the repository", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// owlSupports is the range of Betterbird versions each pinned Owl declares in its manifest.json
|
||||
// (browser_specific_settings.gecko strict_min_version, strict_max_version), read from the vendor's xpi
|
||||
// when its pin was taken. Moving Owl's pin means reading the new xpi's range into here.
|
||||
var owlSupports = map[string][2]string{
|
||||
"1.5.6.1": {"115.0", "156.*"},
|
||||
}
|
||||
|
||||
// owlAccepts says why a Betterbird version is outside the range an Owl version declares, or nil.
|
||||
func owlAccepts(owl, betterbird string) error {
|
||||
r, known := owlSupports[owl]
|
||||
if !known {
|
||||
return fmt.Errorf("Owl %s's supported range is not recorded in owlSupports: read it from the xpi's manifest.json", owl)
|
||||
}
|
||||
major := func(v string) int {
|
||||
head, _, _ := strings.Cut(v, ".")
|
||||
n, err := strconv.Atoi(head)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
b, lo, hi := major(betterbird), major(r[0]), major(r[1])
|
||||
if b < 0 || b < lo || b > hi {
|
||||
return fmt.Errorf("Betterbird %s is outside the range Owl %s supports (%s to %s): a Betterbird Owl does not support "+
|
||||
"waits for Owl's pin to move (novox/hq ADR 0316 §8)", betterbird, owl, r[0], r[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestABetterbirdOwlDoesNotSupportIsRefused(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
owl, bb := fmt.Sprint(artifact(t, m, "owl")["version"]), fmt.Sprint(artifact(t, m, "archive")["version"])
|
||||
if err := owlAccepts(owl, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, outside := range []string{"157.0esr-bb1", "102.15.1esr-bb4"} {
|
||||
if err := owlAccepts(owl, outside); err == nil {
|
||||
t.Errorf("Betterbird %s was accepted with Owl %s", outside, owl)
|
||||
}
|
||||
}
|
||||
if err := owlAccepts("9.9", bb); err == nil {
|
||||
t.Error("an Owl whose range nobody read was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if a := artifact(t, m, "distribution"); a["kind"] != "archive" || a["from"] != "install/distribution" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := policyProblem(raw); why != "" {
|
||||
t.Fatalf("the module's policies.json: %s", why)
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join("..", "..", "install", "distribution"))
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("the distribution directory holds the policy alone: %v", entries)
|
||||
}
|
||||
if a := artifact(t, m, "defaults"); a["kind"] != "archive" || a["from"] != "install/defaults-pref" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
prefs, err := os.ReadFile(filepath.Join("..", "..", "install", "defaults-pref", "vendor-prefs.js"))
|
||||
if err != nil || !strings.Contains(string(prefs), `pref("spellchecker.dictionary_path", "/usr/share/hunspell");`) {
|
||||
t.Fatalf("the dictionaries are named as a default, in place of a link: %s %v", prefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
start := resource(t, m, "start")
|
||||
content := templateText(t, start)
|
||||
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
||||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
||||
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
||||
}
|
||||
launcher := resource(t, m, "launcher")
|
||||
lc := templateText(t, launcher)
|
||||
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
||||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
||||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
||||
t.Fatalf("the launcher starts the module's start and names the icon in the installation: %v", launcher)
|
||||
}
|
||||
if b := artifact(t, m, "tools"); filepath.Base(preparation) != b["binary"] {
|
||||
t.Fatalf("the preparation runs the tools' binary: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed betterbird_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
b := artifact(t, m, "tools")
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
// The tools propose through the controller's settings verb (ADR 0317 §9), and betterbird_derive asks the controller
|
||||
// which nodes hold the module and each of them for its profile (ADR 0329 §10).
|
||||
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings", "seat:mesh-controller.nodes",
|
||||
"seat:mesh-controller.node", "betterbird.betterbird_derive"}) {
|
||||
t.Errorf("the module invokes the controller's settings, nodes and node verbs and its own derive: %v", m.Invokes)
|
||||
}
|
||||
}
|
||||
@@ -1,749 +0,0 @@
|
||||
package main
|
||||
|
||||
// How the five settings of ADR 0329 reach the profile (§7–§9).
|
||||
//
|
||||
// Identities, calendars, address books and tags are matched as accounts are (ADR 0317): an entry the module
|
||||
// recorded on this node is kept by its recorded key; else an entry under the module's own key is its creation;
|
||||
// else a key a node's layer names is adopted when the profile holds it and it matches; else the profile's
|
||||
// entries that match (an identity by its address within its account, a calendar and an address book by their
|
||||
// address, a tag by its key): one is adopted, none means the entry is created under the module's own key, more
|
||||
// than one is refused with the remedy. Their declared fields are written into the user.js the module places,
|
||||
// where they win at every start. An entry leaving the setting releases what was adopted and removes the
|
||||
// preferences of what was created; never a calendar's events, an address book's contacts or a cache.
|
||||
//
|
||||
// What Betterbird rewrites itself is written only while it does not run (§9): the lists in prefs.js (an adopted
|
||||
// account's identities, the calendars' order) and each account's msgFilterRules.dat. Until then the module's
|
||||
// check counts, per kind, the changes that wait.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// configRecordFile is where the module records, per profile, what it adopted or created of the five kinds and
|
||||
// which filter files it wrote (ADR 0329 §7, §8, ADR 0317 §3).
|
||||
const configRecordFile = ".config/betterbird/configuration-record.json"
|
||||
|
||||
// resolvedEntry is one declared entry as it resolved on this node.
|
||||
type resolvedEntry struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
How string `json:"how"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
type recordedEntry struct {
|
||||
How string `json:"how"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// filterRecord is what the module did with one account's filter file on a profile.
|
||||
type filterRecord struct {
|
||||
// File is the account's filter file as the module last wrote it, and Kept the file it replaced at its first write.
|
||||
File string `json:"file"`
|
||||
Kept string `json:"kept,omitempty"`
|
||||
// Released says the account left the setting filters: the module no longer writes its file (§8).
|
||||
Released bool `json:"released,omitempty"`
|
||||
}
|
||||
|
||||
type configRecord struct {
|
||||
Entries map[string]map[string]recordedEntry `json:"entries,omitempty"`
|
||||
Filters map[string]filterRecord `json:"filters,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) readConfigRecord(profileDir string) configRecord {
|
||||
all := map[string]configRecord{}
|
||||
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
|
||||
_ = json.Unmarshal(raw, &all)
|
||||
}
|
||||
r := all[profileDir]
|
||||
if r.Entries == nil {
|
||||
r.Entries = map[string]map[string]recordedEntry{}
|
||||
}
|
||||
if r.Filters == nil {
|
||||
r.Filters = map[string]filterRecord{}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (m *Machine) writeConfigRecord(profileDir string, r configRecord) error {
|
||||
all := map[string]configRecord{}
|
||||
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
|
||||
_ = json.Unmarshal(raw, &all)
|
||||
}
|
||||
all[profileDir] = r
|
||||
body, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(m.home(configRecordFile), append(body, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// writeAtomic writes a file beside itself and renames it into place.
|
||||
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-new"
|
||||
if err := writeSynced(tmp, data, mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The module's own keys for what it creates: each names the entry.
|
||||
func ownIdentityKey(name string) string { return "ident_mesh_" + name }
|
||||
func ownCalendarKey(name string) string { return "mesh_" + name }
|
||||
func ownBookKey(name string) string { return "mesh_" + name }
|
||||
|
||||
var (
|
||||
ownIdentity = regexp.MustCompile(`^ident_mesh_[a-z][a-z0-9]{0,30}$`)
|
||||
ownMeshKey = regexp.MustCompile(`^mesh_[a-z][a-z0-9]{0,30}$`)
|
||||
)
|
||||
|
||||
// configPlan is what an apply does for the five kinds on this profile.
|
||||
type configPlan struct {
|
||||
Entries []resolvedEntry
|
||||
// lines are the user.js statements, a comment before each entry's.
|
||||
lines []string
|
||||
// identityLists are, per adopted account key, the identity keys of the module's own it wants in its list.
|
||||
identityLists map[string][]string
|
||||
// calendarOrder are the calendar keys the module created, for calendar.list.sortOrder.
|
||||
calendarOrder []string
|
||||
// removals are the preference prefixes of what the module created and the setting no longer holds.
|
||||
removals []string
|
||||
filters []filterPlan
|
||||
// released are the accounts that left the setting filters since the module last wrote their file.
|
||||
released []string
|
||||
record configRecord
|
||||
problem string
|
||||
}
|
||||
|
||||
// filterPlan is one declared account's filter file on this node.
|
||||
type filterPlan struct {
|
||||
Account string `json:"account"`
|
||||
File string `json:"file,omitempty"`
|
||||
// Waiting says why the file cannot be written yet, when it cannot.
|
||||
Waiting string `json:"waiting,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
want string
|
||||
have string
|
||||
exists bool
|
||||
path string
|
||||
}
|
||||
|
||||
func (p filterPlan) changes() bool { return p.Refused == "" && p.path != "" && p.want != p.have }
|
||||
|
||||
// planConfiguration resolves every declared entry against the profile and says what an apply writes.
|
||||
func (m *Machine) planConfiguration(profile Profile, c Configuration, accounts map[string]Account,
|
||||
ra []resolvedAccount, prefs map[string]string) configPlan {
|
||||
rec := m.readConfigRecord(profile.Dir)
|
||||
plan := configPlan{identityLists: map[string][]string{},
|
||||
record: configRecord{Entries: map[string]map[string]recordedEntry{}, Filters: map[string]filterRecord{}}}
|
||||
byName := map[string]resolvedAccount{}
|
||||
for _, r := range ra {
|
||||
byName[r.name] = r
|
||||
}
|
||||
have := profileAccounts(prefs)
|
||||
listOfAccount := map[string][]string{}
|
||||
for _, a := range have {
|
||||
listOfAccount[a.Key] = a.Identities
|
||||
}
|
||||
identityKeyOf := map[string]string{}
|
||||
|
||||
// Identities.
|
||||
taken := map[string]bool{}
|
||||
for _, name := range sortedKeys(c.Identities) {
|
||||
id := c.Identities[name]
|
||||
e := resolvedEntry{Kind: kindIdentities, Name: name}
|
||||
acct, declared := byName[id.Account]
|
||||
switch {
|
||||
case !declared:
|
||||
e.How, e.Why = refused, fmt.Sprintf("its account %s is not in the setting accounts", id.Account)
|
||||
case acct.How == refused:
|
||||
e.How, e.Why = refused, fmt.Sprintf("its account %s is refused: betterbird_check says why", id.Account)
|
||||
case id.Address == "":
|
||||
e.How, e.Key = acct.How, acct.Keys.Identity
|
||||
if taken[e.Key] {
|
||||
e.How, e.Key, e.Why = refused, "", fmt.Sprintf("account %s has its default identity declared twice", id.Account)
|
||||
}
|
||||
default:
|
||||
list := listOfAccount[acct.Keys.Account]
|
||||
var others []string
|
||||
if len(list) > 1 {
|
||||
others = list[1:]
|
||||
}
|
||||
present := func(k string) bool {
|
||||
for _, x := range others {
|
||||
if x == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
matches := func(k string) bool {
|
||||
return strings.EqualFold(unquote(prefs["mail.identity."+k+".useremail"]), id.Address)
|
||||
}
|
||||
e = resolveEntry(kindIdentities, name, id.Key, ownIdentityKey(name), rec.Entries[kindIdentities][name],
|
||||
others, present, matches, taken, "an identity of its account with its address",
|
||||
"give the one to adopt as key in this node's layer of the setting identities")
|
||||
if e.How == created {
|
||||
plan.identityLists[acct.Keys.Account] = append(plan.identityLists[acct.Keys.Account], e.Key)
|
||||
}
|
||||
}
|
||||
if e.How != refused {
|
||||
taken[e.Key] = true
|
||||
identityKeyOf[name] = e.Key
|
||||
plan.lines = append(plan.lines, fmt.Sprintf("// identity %s: %s under %s", name, e.How, e.Key))
|
||||
plan.lines = append(plan.lines, identityLines(e, id, acct)...)
|
||||
}
|
||||
plan.Entries = append(plan.Entries, e)
|
||||
}
|
||||
// A created account's identity list is the module's whole: its default and the identities it created.
|
||||
for _, name := range sortedKeys(byName) {
|
||||
r := byName[name]
|
||||
if r.How == created && len(plan.identityLists[r.Keys.Account]) > 0 {
|
||||
list := append([]string{r.Keys.Identity}, plan.identityLists[r.Keys.Account]...)
|
||||
plan.lines = append(plan.lines, fmt.Sprintf("user_pref(%s, %s);", quoted("mail.account."+r.Keys.Account+".identities"),
|
||||
quoted(strings.Join(list, ","))))
|
||||
delete(plan.identityLists, r.Keys.Account)
|
||||
}
|
||||
}
|
||||
// Adopted accounts keep their list in prefs.js: the module's own keys are written into it while Betterbird is
|
||||
// closed. An adopted account whose list holds none of them and wants none is left out.
|
||||
for _, a := range have {
|
||||
if _, wanted := plan.identityLists[a.Key]; wanted {
|
||||
continue
|
||||
}
|
||||
for _, k := range a.Identities {
|
||||
if ownIdentity.MatchString(k) {
|
||||
plan.identityLists[a.Key] = []string{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calendars.
|
||||
cals := profileCalendars(prefs)
|
||||
calKeys := map[string]profileCalendar{}
|
||||
var caldav []string
|
||||
for _, pc := range cals {
|
||||
calKeys[pc.Key] = pc
|
||||
if pc.Kind == "caldav" {
|
||||
caldav = append(caldav, pc.Key)
|
||||
}
|
||||
}
|
||||
taken = map[string]bool{}
|
||||
for _, name := range sortedKeys(c.Calendars) {
|
||||
cal := c.Calendars[name]
|
||||
present := func(k string) bool { pc, ok := calKeys[k]; return ok && pc.Kind == "caldav" }
|
||||
matches := func(k string) bool { return sameURL(calKeys[k].Value.URL, cal.URL) }
|
||||
e := resolveEntry(kindCalendars, name, cal.Key, ownCalendarKey(name), rec.Entries[kindCalendars][name],
|
||||
caldav, present, matches, taken, "a CalDAV calendar at its address",
|
||||
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting calendars")
|
||||
if e.How != refused {
|
||||
taken[e.Key] = true
|
||||
if e.How == created {
|
||||
plan.calendarOrder = append(plan.calendarOrder, e.Key)
|
||||
}
|
||||
plan.lines = append(plan.lines, fmt.Sprintf("// calendar %s: %s under %s", name, e.How, e.Key))
|
||||
plan.lines = append(plan.lines, calendarLines(e, cal, identityKeyOf[cal.Invitations])...)
|
||||
}
|
||||
plan.Entries = append(plan.Entries, e)
|
||||
}
|
||||
|
||||
// Address books.
|
||||
books := profileAddressBooks(prefs)
|
||||
bookKeys := map[string]profileAddressBook{}
|
||||
var carddav []string
|
||||
for _, b := range books {
|
||||
bookKeys[b.Key] = b
|
||||
if b.CardDAV {
|
||||
carddav = append(carddav, b.Key)
|
||||
}
|
||||
}
|
||||
taken = map[string]bool{}
|
||||
for _, name := range sortedKeys(c.AddressBooks) {
|
||||
ab := c.AddressBooks[name]
|
||||
present := func(k string) bool { b, ok := bookKeys[k]; return ok && b.CardDAV }
|
||||
matches := func(k string) bool { return sameURL(bookKeys[k].Value.URL, ab.URL) }
|
||||
e := resolveEntry(kindAddressBooks, name, ab.Key, ownBookKey(name), rec.Entries[kindAddressBooks][name],
|
||||
carddav, present, matches, taken, "a CardDAV address book at its address",
|
||||
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting address-books")
|
||||
if e.How != refused {
|
||||
taken[e.Key] = true
|
||||
plan.lines = append(plan.lines, fmt.Sprintf("// address book %s: %s under %s", name, e.How, e.Key))
|
||||
plan.lines = append(plan.lines, addressBookLines(e, ab, name)...)
|
||||
}
|
||||
plan.Entries = append(plan.Entries, e)
|
||||
}
|
||||
|
||||
// Tags: a tag's key is its own, unique in a profile, so a match is never ambiguous.
|
||||
tagKeys := map[string]bool{}
|
||||
for _, t := range profileTags(prefs) {
|
||||
tagKeys[t.Key] = true
|
||||
}
|
||||
for _, key := range sortedKeys(c.Tags) {
|
||||
t := c.Tags[key]
|
||||
e := resolvedEntry{Kind: kindTags, Name: key, Key: key, How: adopted}
|
||||
if was, ok := rec.Entries[kindTags][key]; ok && was.How == created {
|
||||
e.How = created
|
||||
} else if !tagKeys[key] {
|
||||
e.How = created
|
||||
}
|
||||
plan.lines = append(plan.lines, fmt.Sprintf("// tag %s: %s", key, e.How))
|
||||
plan.lines = append(plan.lines, tagLines(key, t)...)
|
||||
plan.Entries = append(plan.Entries, e)
|
||||
}
|
||||
|
||||
// What the setting no longer holds: an adopted entry is released (its lines go), a created one's preferences
|
||||
// are removed from prefs.js while Betterbird is closed.
|
||||
declaredNow := map[string]map[string]bool{}
|
||||
for _, e := range plan.Entries {
|
||||
if declaredNow[e.Kind] == nil {
|
||||
declaredNow[e.Kind] = map[string]bool{}
|
||||
}
|
||||
declaredNow[e.Kind][e.Name] = true
|
||||
if e.How != refused {
|
||||
if plan.record.Entries[e.Kind] == nil {
|
||||
plan.record.Entries[e.Kind] = map[string]recordedEntry{}
|
||||
}
|
||||
plan.record.Entries[e.Kind][e.Name] = recordedEntry{How: e.How, Key: e.Key}
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindTags} {
|
||||
for _, name := range sortedKeys(rec.Entries[kind]) {
|
||||
was := rec.Entries[kind][name]
|
||||
if declaredNow[kind][name] || was.How != created {
|
||||
continue
|
||||
}
|
||||
switch kind {
|
||||
case kindIdentities:
|
||||
plan.removals = append(plan.removals, "mail.identity."+was.Key+".")
|
||||
case kindCalendars:
|
||||
plan.removals = append(plan.removals, "calendar.registry."+was.Key+".")
|
||||
case kindAddressBooks:
|
||||
plan.removals = append(plan.removals, "ldap_2.servers."+was.Key+".")
|
||||
case kindTags:
|
||||
plan.removals = append(plan.removals, "mailnews.tags."+was.Key+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters, owned whole per declared account (§8).
|
||||
for _, account := range sortedAccountsOf(c.Filters) {
|
||||
fp := filterPlan{Account: account}
|
||||
r, ok := byName[account]
|
||||
switch {
|
||||
case !ok:
|
||||
fp.Refused = fmt.Sprintf("its account %s is not in the setting accounts", account)
|
||||
case r.How == refused:
|
||||
fp.Refused = fmt.Sprintf("its account %s is refused: betterbird_check says why", account)
|
||||
default:
|
||||
dir := m.accountDirectory(profile.path, prefs, r.Keys.Server)
|
||||
if dir == "" {
|
||||
fp.Waiting = "Betterbird has not made the account's directory yet: it does at its first start with the account"
|
||||
break
|
||||
}
|
||||
fp.path = filepath.Join(dir, filterFileName)
|
||||
fp.File = m.tilde(strings.TrimPrefix(fp.path, m.Root))
|
||||
fp.want = renderFilters(c.Filters[account], folderBase(prefs, r.Keys.Server))
|
||||
raw, err := readBounded(fp.path)
|
||||
fp.exists = err == nil
|
||||
fp.have = string(raw)
|
||||
}
|
||||
was := rec.Filters[account]
|
||||
was.Released = false
|
||||
if fp.path != "" {
|
||||
was.File = fp.File
|
||||
}
|
||||
if was.File != "" || was.Kept != "" {
|
||||
plan.record.Filters[account] = was
|
||||
}
|
||||
plan.filters = append(plan.filters, fp)
|
||||
}
|
||||
for _, account := range sortedKeys(rec.Filters) {
|
||||
if _, still := c.Filters[account]; still {
|
||||
continue
|
||||
}
|
||||
was := rec.Filters[account]
|
||||
if !was.Released {
|
||||
plan.released = append(plan.released, account)
|
||||
}
|
||||
was.Released = true
|
||||
plan.record.Filters[account] = was
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// resolveEntry resolves an identity, a calendar or an address book as ADR 0317 resolves an account (§7).
|
||||
func resolveEntry(kind, name, layerKey, own string, was recordedEntry, candidates []string, present, matches func(string) bool,
|
||||
taken map[string]bool, what, remedy string) resolvedEntry {
|
||||
e := resolvedEntry{Kind: kind, Name: name}
|
||||
refuse := func(format string, args ...any) resolvedEntry {
|
||||
e.How, e.Key, e.Why = refused, "", fmt.Sprintf(format, args...)
|
||||
return e
|
||||
}
|
||||
// 1. Recorded, while the profile still holds it.
|
||||
if was.Key != "" && !taken[was.Key] && (present(was.Key) || was.How == created) && (layerKey == "" || layerKey == was.Key) {
|
||||
e.How, e.Key = was.How, was.Key
|
||||
return e
|
||||
}
|
||||
// 2. Under the module's own key: its creation.
|
||||
if present(own) {
|
||||
e.How, e.Key = created, own
|
||||
return e
|
||||
}
|
||||
// 3. The key a node's layer names.
|
||||
if layerKey != "" {
|
||||
switch {
|
||||
case !present(layerKey):
|
||||
return refuse("the key %s this node's layer names is not in the profile", layerKey)
|
||||
case taken[layerKey]:
|
||||
return refuse("the key %s is already another declared entry's", layerKey)
|
||||
case !matches(layerKey):
|
||||
return refuse("the key %s this node's layer names is not %s", layerKey, what)
|
||||
}
|
||||
e.How, e.Key = adopted, layerKey
|
||||
return e
|
||||
}
|
||||
// 4. By the match.
|
||||
var found []string
|
||||
for _, k := range candidates {
|
||||
if !taken[k] && !ownMeshKey.MatchString(k) && !ownIdentity.MatchString(k) && matches(k) {
|
||||
found = append(found, k)
|
||||
}
|
||||
}
|
||||
switch len(found) {
|
||||
case 0:
|
||||
e.How, e.Key = created, own
|
||||
case 1:
|
||||
e.How, e.Key = adopted, found[0]
|
||||
default:
|
||||
return refuse("%d entries in the profile are %s (%s): %s (novox/hq ADR 0329 §7)", len(found), what,
|
||||
strings.Join(found, ", "), remedy)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// sameURL compares two addresses without a trailing slash's difference.
|
||||
func sameURL(a, b string) bool {
|
||||
return a != "" && strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||
}
|
||||
|
||||
func pref(name, value string) string { return fmt.Sprintf("user_pref(%s, %s);", quoted(name), value) }
|
||||
|
||||
func boolWord(b bool) string { return strconv.FormatBool(b) }
|
||||
|
||||
// identityLines are an identity's declared fields under its key. The default identity's name and address are
|
||||
// the setting accounts' (§4), so only another identity carries them here.
|
||||
func identityLines(e resolvedEntry, id *Identity, acct resolvedAccount) []string {
|
||||
p := "mail.identity." + e.Key + "."
|
||||
var out []string
|
||||
if id.Address != "" {
|
||||
out = append(out, pref(p+"fullName", jsQuoted(id.Name)), pref(p+"useremail", jsQuoted(id.Address)))
|
||||
if e.How == created {
|
||||
out = append(out, pref(p+"valid", "true"), pref(p+"smtpServer", quoted(acct.Keys.SMTP)))
|
||||
}
|
||||
}
|
||||
above := 0
|
||||
if id.SignatureAboveQuote {
|
||||
above = 1
|
||||
}
|
||||
out = append(out,
|
||||
pref(p+"reply_to", jsQuoted(id.ReplyTo)),
|
||||
pref(p+"organization", jsQuoted(id.Organization)),
|
||||
pref(p+"htmlSigText", jsQuoted(strings.Join(id.Signature, "\n"))),
|
||||
pref(p+"htmlSigFormat", boolWord(id.SignatureHTML)),
|
||||
pref(p+"attach_signature", "false"),
|
||||
pref(p+"reply_on_top", strconv.Itoa(above)),
|
||||
pref(p+"sig_bottom", boolWord(!id.SignatureAboveQuote)))
|
||||
return out
|
||||
}
|
||||
|
||||
func calendarLines(e resolvedEntry, cal *Calendar, identityKey string) []string {
|
||||
p := "calendar.registry." + e.Key + "."
|
||||
var out []string
|
||||
if e.How == created {
|
||||
out = append(out, pref(p+"type", quoted("caldav")), pref(p+"cache.enabled", "true"),
|
||||
pref(p+"calendar-main-in-composite", "true"))
|
||||
}
|
||||
reminders := cal.Reminders == nil || *cal.Reminders
|
||||
out = append(out, pref(p+"uri", jsQuoted(cal.URL)), pref(p+"name", jsQuoted(cal.Name)))
|
||||
if cal.Color != "" {
|
||||
out = append(out, pref(p+"color", jsQuoted(strings.ToLower(cal.Color))))
|
||||
}
|
||||
out = append(out, pref(p+"readOnly", boolWord(cal.ReadOnly)), pref(p+"suppressAlarms", boolWord(!reminders)),
|
||||
pref(p+"username", jsQuoted(cal.User)), pref(p+"imip.identity.key", jsQuoted(identityKey)))
|
||||
return out
|
||||
}
|
||||
|
||||
func addressBookLines(e resolvedEntry, ab *AddressBook, name string) []string {
|
||||
p := "ldap_2.servers." + e.Key + "."
|
||||
var out []string
|
||||
if e.How == created {
|
||||
out = append(out, pref(p+"dirType", "102"), pref(p+"filename", quoted("abook-mesh_"+name+".sqlite")))
|
||||
}
|
||||
return append(out, pref(p+"description", jsQuoted(ab.Name)), pref(p+"carddav.url", jsQuoted(ab.URL)),
|
||||
pref(p+"carddav.username", jsQuoted(ab.User)))
|
||||
}
|
||||
|
||||
func tagLines(key string, t *Tag) []string {
|
||||
p := "mailnews.tags." + key + "."
|
||||
out := []string{pref(p+"tag", jsQuoted(t.Name))}
|
||||
if t.Color != "" {
|
||||
out = append(out, pref(p+"color", jsQuoted(strings.ToUpper(t.Color))))
|
||||
}
|
||||
if t.Order > 0 {
|
||||
out = append(out, pref(p+"ordinal", quoted(tagOrdinal(t.Order))))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// configBlock is the user.js block of the five kinds.
|
||||
func (p configPlan) configBlock() string {
|
||||
if len(p.lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "\n// The identities, calendars, address books and tags the settings declare (novox/hq ADR 0329), under the\n" +
|
||||
"// keys each was adopted or created under. No password, token or cookie.\n" + strings.Join(p.lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
// closedFor says why the profile's own files may not be written now, or "": Betterbird runs, or the profile's
|
||||
// lock says one holds it (§9).
|
||||
func (m *Machine) closedFor(profile string) string {
|
||||
if ps := m.mainProcs(); len(ps) > 0 {
|
||||
return fmt.Sprintf("Betterbird is running (pid %d), and it rewrites these files when it quits", ps[0].PID)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(profile, "lock")); err == nil {
|
||||
return "the profile is locked (its lock file is there): a Betterbird holds it, or one ended without quitting"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// prefsChanges is prefs.js as the plan wants it, and how many changes each kind has in it.
|
||||
func (p configPlan) prefsChanges(text string) (string, map[string]int) {
|
||||
counts := map[string]int{}
|
||||
var lines []string
|
||||
done := map[string]bool{}
|
||||
s := bufio.NewScanner(strings.NewReader(text))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
mm := prefLine.FindStringSubmatch(line)
|
||||
if mm == nil {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
name := mm[1]
|
||||
removed := false
|
||||
for _, prefix := range p.removals {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
removed = true
|
||||
counts[kindOfPrefix(prefix)]++
|
||||
break
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
continue
|
||||
}
|
||||
if acct, ok := strings.CutPrefix(name, "mail.account."); ok && strings.HasSuffix(acct, ".identities") {
|
||||
key := strings.TrimSuffix(acct, ".identities")
|
||||
if want, ok := p.identityLists[key]; ok {
|
||||
done[name] = true
|
||||
next := listedWithOwn(listOf(mm[2]), want, ownIdentity.MatchString, ",")
|
||||
if next != strings.Join(listOf(mm[2]), ",") {
|
||||
line = pref(name, quoted(next))
|
||||
counts[kindIdentities]++
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "calendar.list.sortOrder" {
|
||||
done[name] = true
|
||||
have := strings.Fields(unquote(mm[2]))
|
||||
next := listedWithOwn(have, p.calendarOrder, ownMeshKey.MatchString, " ")
|
||||
if next != strings.Join(have, " ") {
|
||||
line = pref(name, quoted(next))
|
||||
counts[kindCalendars]++
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if !done["calendar.list.sortOrder"] && len(p.calendarOrder) > 0 {
|
||||
lines = append(lines, pref("calendar.list.sortOrder", quoted(strings.Join(p.calendarOrder, " "))))
|
||||
counts[kindCalendars]++
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if text == "" || strings.HasSuffix(text, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, counts
|
||||
}
|
||||
|
||||
func kindOfPrefix(prefix string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(prefix, "mail.identity."):
|
||||
return kindIdentities
|
||||
case strings.HasPrefix(prefix, "calendar."):
|
||||
return kindCalendars
|
||||
case strings.HasPrefix(prefix, "ldap_2."):
|
||||
return kindAddressBooks
|
||||
}
|
||||
return kindTags
|
||||
}
|
||||
|
||||
// listedWithOwn is a list with the module's own keys made the wanted ones, every other key in its place.
|
||||
func listedWithOwn(have, wanted []string, own func(string) bool, sep string) string {
|
||||
want := map[string]bool{}
|
||||
for _, k := range wanted {
|
||||
want[k] = true
|
||||
}
|
||||
var out []string
|
||||
there := map[string]bool{}
|
||||
for _, k := range have {
|
||||
if own(k) && !want[k] {
|
||||
continue
|
||||
}
|
||||
out = append(out, k)
|
||||
there[k] = true
|
||||
}
|
||||
for _, k := range wanted {
|
||||
if !there[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, sep)
|
||||
}
|
||||
|
||||
// Waiting counts, per kind, the changes of what Betterbird rewrites that wait for it to close (§9).
|
||||
func (m *Machine) waiting(profile string, p configPlan, ra []resolvedAccount) map[string]int {
|
||||
raw, _ := readBounded(filepath.Join(profile, "prefs.js"))
|
||||
_, counts := p.prefsChanges(string(raw))
|
||||
var wantAccounts, wantSMTP []string
|
||||
for _, r := range ra {
|
||||
if r.How == created {
|
||||
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
|
||||
}
|
||||
}
|
||||
if _, changed := withListed(string(raw), map[string][]string{
|
||||
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}); changed {
|
||||
counts["accounts"]++
|
||||
}
|
||||
for _, f := range p.filters {
|
||||
if f.changes() {
|
||||
counts[kindFilters]++
|
||||
}
|
||||
}
|
||||
for k, v := range counts {
|
||||
if v == 0 {
|
||||
delete(counts, k)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// writeClosed writes what Betterbird rewrites itself, only while it is closed: prefs.js's lists and the
|
||||
// removals, and each declared account's filter file, keeping the file it replaces at its first write. It
|
||||
// answers what it wrote, or why it waits.
|
||||
func (m *Machine) writeClosed(profile Profile, p *configPlan) (string, error) {
|
||||
var said []string
|
||||
path := filepath.Join(profile.path, "prefs.js")
|
||||
raw, err := readBounded(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
next, counts := p.prefsChanges(string(raw))
|
||||
pending := next != string(raw)
|
||||
for _, f := range p.filters {
|
||||
pending = pending || f.changes()
|
||||
}
|
||||
if !pending {
|
||||
return "unchanged", nil
|
||||
}
|
||||
if why := m.closedFor(profile.path); why != "" {
|
||||
return "", errors.New(why + ": the identities' and calendars' lists, the removals and the filter files are " +
|
||||
"written at its next start from the launcher, or by betterbird_restart")
|
||||
}
|
||||
if next != string(raw) {
|
||||
mode := os.FileMode(0o600)
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
if err := writeAtomic(path, []byte(next), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
said = append(said, fmt.Sprintf("prefs.js: %d change(s) to the lists and removals", sum(counts)))
|
||||
}
|
||||
for i, f := range p.filters {
|
||||
if !f.changes() {
|
||||
continue
|
||||
}
|
||||
was := p.record.Filters[f.Account]
|
||||
if was.Kept == "" && f.exists {
|
||||
kept := m.keptFilterFile(profile.Dir, f.Account)
|
||||
if err := writeAtomic(kept, []byte(f.have), 0o600); err != nil {
|
||||
return strings.Join(said, "; "), fmt.Errorf("the filter file of %s was not written: the file it replaces could not be kept: %v", f.Account, err)
|
||||
}
|
||||
was.Kept = m.tilde(strings.TrimPrefix(kept, m.Root))
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if st, err := os.Stat(f.path); err == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
if err := writeAtomic(f.path, []byte(f.want), mode); err != nil {
|
||||
return strings.Join(said, "; "), err
|
||||
}
|
||||
was.File = f.File
|
||||
p.record.Filters[f.Account] = was
|
||||
p.filters[i].have, p.filters[i].exists = f.want, true
|
||||
said = append(said, "the filters of "+f.Account)
|
||||
}
|
||||
if len(said) == 0 {
|
||||
return "unchanged", nil
|
||||
}
|
||||
return "written: " + strings.Join(said, "; "), nil
|
||||
}
|
||||
|
||||
func sum(m map[string]int) int {
|
||||
n := 0
|
||||
for _, v := range m {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "" when
|
||||
// they name none within the profile.
|
||||
func (m *Machine) accountDirectory(profile string, prefs map[string]string, server string) string {
|
||||
if dir := accountDirectory(profile, prefs, server); dir != "" {
|
||||
return dir
|
||||
}
|
||||
abs := unquote(prefs["mail.server."+server+".directory"])
|
||||
if abs == "" {
|
||||
return ""
|
||||
}
|
||||
dir := m.path(abs)
|
||||
if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) {
|
||||
return dir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(bytes.TrimSpace(b))
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package main
|
||||
|
||||
// The setting prefs, one entry per preference (novox/hq issue 586, ADR 0329; ADR 0316 §11 for the user.js).
|
||||
//
|
||||
// Until issue 586 the setting was one line of user_pref(…) statements, so a node's layer replaced it whole and
|
||||
// the operator's own choices were shipped in the catalogue as the module's defaults file. Now the setting is a
|
||||
// map from a preference's name to its value, and reaches the machine as ~/.config/betterbird/prefs.json, a
|
||||
// file the node-engine merges from the module's layers key by key: the mesh-wide layer holds the operator's
|
||||
// setup, and a node's layer overrides one preference, or takes one out with null. The module itself ships only
|
||||
// its policy's locks and Betterbird's own defaults (mesh-defaults.js names the preferences it manages there).
|
||||
//
|
||||
// The tools turn the setting into the head of the user.js they place in the profile, where Betterbird reads it
|
||||
// at its start and lets it win over what it saved itself. A preference that can hold a credential, or that
|
||||
// another setting owns (an account's, an identity's, a calendar's, an address book's, a tag's), is refused, so
|
||||
// each value has one owner (ADR 0316 §11, ADR 0329 §6).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// prefsFile is the setting prefs on the machine, written by the node-engine.
|
||||
const prefsFile = ".config/betterbird/prefs.json"
|
||||
|
||||
// configurationOwned are the preferences the settings of ADR 0329 own: the setting prefs refuses them (§6).
|
||||
var configurationOwned = []string{"calendar.registry.", "calendar.list.", "ldap_2.servers.", "mailnews.tags."}
|
||||
|
||||
// prefName is a preference's name as the setting may give it.
|
||||
var prefName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.\-\[\]@{}]{0,199}$`)
|
||||
|
||||
// prefOwner says why the setting prefs may not set a preference, or "" when it may.
|
||||
func prefOwner(name string) string {
|
||||
if passwordSecret.MatchString(name) {
|
||||
return "a preference that can hold a credential: not through the mesh"
|
||||
}
|
||||
for _, p := range accountOwned {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return "an account's preference: an account is declared in the setting accounts, never in prefs (novox/hq ADR 0316 §11)"
|
||||
}
|
||||
}
|
||||
for _, p := range configurationOwned {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return "owned by the setting calendars, address-books or tags, never by prefs (novox/hq ADR 0329 §6)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readDeclaredPrefs reads the setting prefs on the machine: each preference's value as user.js writes it. A
|
||||
// missing file says the module is not pushed here in this form; a file that names a preference another setting
|
||||
// owns, or holds a value user.js cannot carry, is refused whole, naming the preference.
|
||||
func (m *Machine) readDeclaredPrefs() (map[string]string, bool, error) {
|
||||
raw, err := readBounded(m.home(prefsFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]string{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
prefs, err := parseDeclaredPrefs(raw)
|
||||
return prefs, true, err
|
||||
}
|
||||
|
||||
func parseDeclaredPrefs(raw []byte) (map[string]string, error) {
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil, fmt.Errorf("~/%s is not a JSON object: %v", prefsFile, err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
body, ok := top["prefs"]
|
||||
if !ok || string(body) == "null" {
|
||||
return out, nil
|
||||
}
|
||||
var values map[string]any
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&values); err != nil {
|
||||
return nil, fmt.Errorf("the setting prefs is a map from a preference's name to its value: %v", err)
|
||||
}
|
||||
var problems []string
|
||||
for _, name := range sortedKeys(values) {
|
||||
if !prefName.MatchString(name) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a preference's name", name))
|
||||
continue
|
||||
}
|
||||
if why := prefOwner(name); why != "" {
|
||||
problems = append(problems, name+": "+why)
|
||||
continue
|
||||
}
|
||||
lit, err := prefLiteral(values[name])
|
||||
if err != nil {
|
||||
problems = append(problems, name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if lit != "" {
|
||||
out[name] = lit
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the setting prefs is refused: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// prefLiteral is a setting's value as user.js writes it: true or false, a whole number, or a string. null is
|
||||
// no value: a node's layer takes the mesh-wide one out with it, and Betterbird's own default holds there.
|
||||
func prefLiteral(v any) (string, error) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case bool:
|
||||
return strconv.FormatBool(x), nil
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil || n > math.MaxInt32 || n < math.MinInt32 {
|
||||
return "", fmt.Errorf("%s is not a whole number Betterbird keeps", x)
|
||||
}
|
||||
return strconv.FormatInt(n, 10), nil
|
||||
case float64:
|
||||
if x != math.Trunc(x) || x > math.MaxInt32 || x < math.MinInt32 {
|
||||
return "", fmt.Errorf("%v is not a whole number Betterbird keeps", x)
|
||||
}
|
||||
return strconv.FormatInt(int64(x), 10), nil
|
||||
case string:
|
||||
if strings.ContainsAny(x, lineEnds) {
|
||||
return "", errors.New("a string preference is one line")
|
||||
}
|
||||
return jsQuoted(x), nil
|
||||
}
|
||||
return "", fmt.Errorf("a value is true, false, a whole number or a string, not %T", v)
|
||||
}
|
||||
|
||||
// prefValue is a preference's literal as the setting holds it: the reverse of prefLiteral.
|
||||
func prefValue(lit string) (any, bool) {
|
||||
switch {
|
||||
case lit == "true":
|
||||
return true, true
|
||||
case lit == "false":
|
||||
return false, true
|
||||
}
|
||||
if n, err := strconv.ParseInt(lit, 10, 32); err == nil {
|
||||
return n, true
|
||||
}
|
||||
if strings.HasPrefix(lit, `"`) {
|
||||
if s, err := strconv.Unquote(lit); err == nil {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// lineEnds are the characters a reader may take as a line's end: a setting holds none (novox/hq issue 339).
|
||||
const lineEnds = "\n\r\v\f\x00\u0085
"
|
||||
|
||||
// jsQuoted is a string as a preference file writes it: quote, backslash and the control characters escaped the
|
||||
// way Gecko's preference parser reads them.
|
||||
func jsQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '"':
|
||||
b.WriteString(`\"`)
|
||||
case r == '\\':
|
||||
b.WriteString(`\\`)
|
||||
case r == '\n':
|
||||
b.WriteString(`\n`)
|
||||
case r == '\r':
|
||||
b.WriteString(`\r`)
|
||||
case r == '\t':
|
||||
b.WriteString(`\t`)
|
||||
case r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 || r == 0x85:
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// declaredText is the head of the user.js the module places: its mark and the setting prefs, one statement a
|
||||
// line, by name. present says the setting is on this machine; err says why it is refused.
|
||||
func (m *Machine) declaredText() (string, bool, error) {
|
||||
prefs, present, err := m.readDeclaredPrefs()
|
||||
if err != nil || !present {
|
||||
return "", present, err
|
||||
}
|
||||
return composeDeclared(prefs), true, nil
|
||||
}
|
||||
|
||||
// composeDeclared is the user.js head for a set of declared preferences.
|
||||
func composeDeclared(prefs map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(mark + ", novox/hq issue 345). Owned by the mesh: the tools write it into the profile from the\n" +
|
||||
"// module's settings at Betterbird's start (betterbird_user_js with apply, betterbird_restart, the launcher), and\n" +
|
||||
"// Betterbird reads it at its start, where it wins over what Betterbird saved. Change the settings, never this file.\n" +
|
||||
"//\n// The setting prefs (novox/hq issue 586): the operator's setup, mesh-wide with a node's own layer over it.\n")
|
||||
names := make([]string, 0, len(prefs))
|
||||
for n := range prefs {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, "user_pref(%s, %s);\n", quoted(n), prefs[n])
|
||||
}
|
||||
if len(names) == 0 {
|
||||
b.WriteString("// none\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package main
|
||||
|
||||
// The profile's identities, calendars, address books and tags, read from its preferences into the setting's
|
||||
// shape (novox/hq ADR 0329). The profile is read as Betterbird starts with it: prefs.js, with user.js over it.
|
||||
// Nothing here reads the login store, a calendar's events or an address book's contacts (§13).
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// profileIdentity is one identity the profile holds.
|
||||
type profileIdentity struct {
|
||||
Key string
|
||||
AccountKey string
|
||||
// Default says it is its account's first identity.
|
||||
Default bool
|
||||
Owl bool
|
||||
Value Identity
|
||||
}
|
||||
|
||||
// profileIdentities reads every identity of every account in the profile's list, the default first.
|
||||
func (m *Machine) profileIdentities(prefs map[string]string) []profileIdentity {
|
||||
var out []profileIdentity
|
||||
for _, a := range profileAccounts(prefs) {
|
||||
for i, id := range a.Identities {
|
||||
out = append(out, profileIdentity{Key: id, AccountKey: a.Key, Default: i == 0, Owl: a.Owl,
|
||||
Value: m.readIdentity(prefs, id)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readIdentity reads one identity's fields. A signature the profile reads from a file is read as its text, since
|
||||
// a file's path differs per node (§10).
|
||||
func (m *Machine) readIdentity(prefs map[string]string, key string) Identity {
|
||||
p := "mail.identity." + key + "."
|
||||
text := func(name string) string { return unquote(prefs[p+name]) }
|
||||
id := Identity{Address: text("useremail"), Name: text("fullName"), ReplyTo: text("reply_to"),
|
||||
Organization: text("organization"), SignatureHTML: prefs[p+"htmlSigFormat"] == "true",
|
||||
SignatureAboveQuote: prefs[p+"reply_on_top"] == "1" && prefs[p+"sig_bottom"] == "false"}
|
||||
sig := text("htmlSigText")
|
||||
if prefs[p+"attach_signature"] == "true" {
|
||||
if file := text("sig_file"); file != "" {
|
||||
sig = m.signatureFile(file)
|
||||
}
|
||||
}
|
||||
if sig = strings.TrimRight(strings.ReplaceAll(sig, "\r\n", "\n"), "\n"); sig != "" {
|
||||
id.Signature = strings.Split(sig, "\n")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// signatureFile is a signature file's text, read only within the home and bounded.
|
||||
func (m *Machine) signatureFile(file string) string {
|
||||
clean := filepath.Clean(file)
|
||||
if !strings.HasPrefix(clean, strings.TrimSuffix(m.Home, "/")+"/") {
|
||||
return ""
|
||||
}
|
||||
raw, err := readBounded(m.path(clean))
|
||||
if err != nil || len(raw) > 64<<10 {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// keysUnder are the distinct second-level keys of the preferences under a prefix: calendar.registry.<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) }
|
||||
@@ -1,199 +0,0 @@
|
||||
package main
|
||||
|
||||
// A reminder on demand, without the operator's calendar: a second Betterbird, in a profile of its own
|
||||
// under the account's runtime directory, with one local calendar file holding one event whose reminder
|
||||
// is due a few seconds after the start. The running Betterbird, its profile, its accounts and its
|
||||
// calendars are not touched: the second one starts with --no-remote, so it neither hands over to the
|
||||
// first nor is handed to.
|
||||
//
|
||||
// The test profile gets the module's user.js too, so the reminder is drawn with the preferences the
|
||||
// module declares. A theme or another preference of the operator's profile can be carried over by
|
||||
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
|
||||
//
|
||||
// **It is visibly a test** (the operator, 2026-10-12): on 2026-10-12 at 12:05 a test Betterbird with an empty
|
||||
// profile carrying the operator's theme looked like the operator's configuration lost. So the test profile
|
||||
// carries a userChrome.css that marks its main window with a red band reading MESH TEST, and the event is
|
||||
// called "MESH TEST reminder". The reminder window itself is left as Betterbird draws it, because how it is
|
||||
// drawn is what the test looks at. The tool's description says a window opens on the operator's screen, so
|
||||
// a caller tells the operator first.
|
||||
//
|
||||
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
|
||||
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
|
||||
// looked at with xorg_screenshot.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testDir is the test's profile and calendar, under the account's runtime directory.
|
||||
func (m *Machine) testDir() string {
|
||||
return m.path(fmt.Sprintf("/run/user/%d/%s", m.UID, testUnit))
|
||||
}
|
||||
|
||||
// carryName is a preference name the test may carry over from the operator's profile.
|
||||
var carryName = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
|
||||
|
||||
// ReminderAnswer is what betterbird_reminder_test answers.
|
||||
type ReminderAnswer struct {
|
||||
Unit string `json:"unit"`
|
||||
Profile string `json:"profile"`
|
||||
Event string `json:"event"`
|
||||
Due string `json:"reminder_due"`
|
||||
Carried []string `json:"carried,omitempty"`
|
||||
Withheld []string `json:"withheld,omitempty"`
|
||||
Look string `json:"look"`
|
||||
OnScreen string `json:"on_screen,omitempty"`
|
||||
Stopped bool `json:"stopped,omitempty"`
|
||||
}
|
||||
|
||||
// ics is the test calendar: one event, its reminder `after` from now.
|
||||
func ics(now time.Time, after time.Duration, stamp string) string {
|
||||
f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
start := now.Add(after + 10*time.Minute)
|
||||
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
|
||||
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
|
||||
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:" + testTitle, "BEGIN:VALARM", "ACTION:DISPLAY",
|
||||
"DESCRIPTION:" + testTitle, "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
|
||||
}
|
||||
|
||||
// testTitle is the test event's title: what the reminder window shows.
|
||||
const testTitle = "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)"
|
||||
|
||||
// testChrome is the test profile's userChrome.css: a red band reading MESH TEST across the top of every
|
||||
// Betterbird window but the reminder (windowtype Calendar:AlarmWindow), and a red frame around it, so the test
|
||||
// Betterbird is never taken for the operator's.
|
||||
const testChrome = `/* The mesh's reminder test (module betterbird, novox/hq issue 345): a throw-away Betterbird, not yours. */
|
||||
:root:not([windowtype="Calendar:AlarmWindow"]) {
|
||||
outline: 6px solid #d00000 !important;
|
||||
outline-offset: -6px !important;
|
||||
}
|
||||
:root:not([windowtype="Calendar:AlarmWindow"]) > body::before {
|
||||
content: "MESH TEST \2014 a throw-away Betterbird started by the mesh for a reminder test; your mail and calendar are not here. It ends by itself within 10 minutes.";
|
||||
display: block !important;
|
||||
background: #d00000 !important;
|
||||
color: #ffffff !important;
|
||||
font: bold 15px sans-serif !important;
|
||||
padding: 6px 12px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
`
|
||||
|
||||
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
|
||||
//
|
||||
// betterbird.update.checkIntervalDays 0 is Betterbird's own switch for its update check (MailGlue): a
|
||||
// fresh profile has never checked, so Betterbird checks at its start and, when betterbird.eu publishes a
|
||||
// newer build than the installed one, opens the About window on the operator's screen and gives it the
|
||||
// focus (seen on the workstation 2026-10-12, novox/hq issue 345). The test is about the reminder only.
|
||||
func testPrefs(calendar string) string {
|
||||
return strings.Join([]string{
|
||||
`user_pref("calendar.registry.meshtest.type", "ics");`,
|
||||
`user_pref("calendar.registry.meshtest.uri", "file://` + calendar + `");`,
|
||||
`user_pref("calendar.registry.meshtest.name", "Mesh reminder test");`,
|
||||
`user_pref("calendar.registry.meshtest.calendar-main-in-composite", true);`,
|
||||
`user_pref("calendar.registry.meshtest.suppressAlarms", false);`,
|
||||
`user_pref("calendar.list.sortOrder", "meshtest");`,
|
||||
`user_pref("mail.provider.suppress_dialog_on_startup", true);`,
|
||||
`user_pref("mail.shell.checkDefaultClient", false);`,
|
||||
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
|
||||
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
|
||||
`user_pref("app.update.auto", false);`,
|
||||
`user_pref("betterbird.update.checkIntervalDays", 0);`,
|
||||
`user_pref("mail.rights.version", 1);`,
|
||||
`user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`,
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// ReminderTest starts the test Betterbird, or with stop ends it and removes its profile.
|
||||
func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (ReminderAnswer, error) {
|
||||
dir := m.testDir()
|
||||
a := ReminderAnswer{Unit: testUnit + ".service", Profile: strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root),
|
||||
Look: "xorg_windows with class Calendar answers the reminder window (role AlarmWindow, its depth and map state); " +
|
||||
"xorg_screenshot with that window shows it as the screen does"}
|
||||
if stop {
|
||||
_ = m.cmd(5*time.Second, m.bus(), "systemctl", "--user", "stop", testUnit+".service")
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Stopped, a.Look = true, ""
|
||||
return a, nil
|
||||
}
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
var carried []string
|
||||
if len(carry) > 0 {
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||
have := readPrefs(prefsJS)
|
||||
for _, n := range carry {
|
||||
switch {
|
||||
case !carryName.MatchString(n):
|
||||
return a, fmt.Errorf("carry holds preference names, not %q", n)
|
||||
case withheld(n) != "":
|
||||
a.Withheld = append(a.Withheld, n+": "+withheld(n))
|
||||
case have[n] == "":
|
||||
a.Withheld = append(a.Withheld, n+": not set in the operator's profile")
|
||||
default:
|
||||
carried = append(carried, fmt.Sprintf("user_pref(%s, %s);", strconv.Quote(n), have[n]))
|
||||
a.Carried = append(a.Carried, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", testUnit+".service")
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "profile"), 0o700); err != nil {
|
||||
return a, err
|
||||
}
|
||||
now := m.Now()
|
||||
calendar := filepath.Join(dir, "test.ics")
|
||||
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
// The module's user.js first, then what is carried, then the test's own preferences: the last
|
||||
// statement wins, so neither can take the test's calendar or its quiet start away.
|
||||
user := ""
|
||||
if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil {
|
||||
user = mine + "\n"
|
||||
}
|
||||
user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root))
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "profile", "chrome"), 0o700); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile", "chrome", "userChrome.css"), []byte(testChrome), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
|
||||
// test Betterbird is closed.
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
|
||||
"-p", "ExecStopPost=/usr/bin/rm -rf " + strings.TrimPrefix(dir, m.Root), "--setenv=MOZ_NO_REMOTE=1"}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--", startBin, "--no-remote", "--profile", strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root))
|
||||
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Event = testTitle + ", in a calendar file of the test profile only"
|
||||
a.OnScreen = "a second Betterbird's window is now on the operator's screen, marked MESH TEST in red; its reminder " +
|
||||
"window follows when the reminder is due. Both go when the test ends (stop, or 10 minutes)"
|
||||
a.Due = now.Add(after).UTC().Format(time.RFC3339)
|
||||
return a, nil
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The review of pull request 155 (hq issue 345): a restart never loses an unsent message unasked, a
|
||||
// preference's value never carries an address, a log never a credential, the reminder test never
|
||||
// counts as the operator's Betterbird and leaves nothing behind, and a restart answers within the
|
||||
// runtime's call limit.
|
||||
|
||||
const composerTree = `
|
||||
0x2a00003 "Inbox - Betterbird": ("Mail" "eu.betterbird.Betterbird") 2560x1388+0+0 +0+26
|
||||
0x2a01234 "Write: hello - Betterbird": ("Msgcompose" "eu.betterbird.Betterbird") 900x700+10+10 +10+10
|
||||
`
|
||||
|
||||
func TestARestartRefusesWhileAComposerIsOpenUnlessTheUnsentMayGo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "xwininfo" {
|
||||
return Output{Stdout: composerTree}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
|
||||
t.Fatalf("refused while a message is being written: %v", err)
|
||||
}
|
||||
if len(f.signals) != 0 {
|
||||
t.Fatalf("nothing was ended: %v", f.signals)
|
||||
}
|
||||
f.onStart = func([]string) {
|
||||
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
|
||||
}
|
||||
a, err := f.Restart(true)
|
||||
if err != nil || len(a.Ended) != 1 || a.Composers != 1 {
|
||||
t.Fatalf("with discard_unsent it goes ahead and says what was open: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARestartAnswersWithinTheCallLimitEvenWhenNothingCooperates(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.stubborn[4000] = true
|
||||
slept := time.Duration(0)
|
||||
f.Sleep = func(d time.Duration) { slept += d }
|
||||
a, err := f.Restart(false)
|
||||
if err != nil {
|
||||
t.Fatalf("a start that has not shown yet is said, not failed: %v", err)
|
||||
}
|
||||
if slept > 18*time.Second || len(a.Killed) != 1 || a.Note == "" {
|
||||
t.Fatalf("waited %v of the runtime's 30 s: %+v", slept, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPreferencesValueCarriesNoAddressHostOrHome(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.write(profileDir+"/prefs.js", strings.Join([]string{
|
||||
`user_pref("mail.last_msg_movecopy_target_uri", "imap://someone%40example.org@mail.example.org/INBOX");`,
|
||||
`user_pref("messenger.account.a1.server", "x");`,
|
||||
`user_pref("mail.cloud_files.accounts.a", "y");`,
|
||||
`user_pref("editor.lastFileLocation", "/home/operator/Documents/a.pdf");`,
|
||||
`user_pref("network.proxy.http", "proxy.corp.example.com");`,
|
||||
`user_pref("mail.compose.default_to", "imap://someone%40example.org@mail.example.org/x and bob@example.org");`,
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`,
|
||||
`user_pref("extensions.ui.lastCategory", "addons://list/theme");`,
|
||||
`user_pref("font.size.variable.x-western", 12);`,
|
||||
}, "\n")+"\n")
|
||||
got, err := f.Prefs([]string{"mail.last_msg*", "messenger.account.", "mail.cloud_files.", "editor.", "network.proxy.",
|
||||
"mail.compose.default_to", "extensions.", "font.size."})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]Pref{}
|
||||
for _, p := range got["prefs"].([]Pref) {
|
||||
by[p.Name] = p
|
||||
}
|
||||
for _, n := range []string{"mail.last_msg_movecopy_target_uri", "messenger.account.a1.server", "mail.cloud_files.accounts.a", "extensions.ui.lastCategory"} {
|
||||
if p := by[n]; p.Value != "" || p.Withheld == "" {
|
||||
t.Errorf("withheld: %s %+v", n, p)
|
||||
}
|
||||
}
|
||||
if v := by["editor.lastFileLocation"].Value; v != `"~/Documents/a.pdf"` {
|
||||
t.Errorf("home as ~: %s", v)
|
||||
}
|
||||
if v := by["network.proxy.http"].Value; strings.Contains(v, "example") {
|
||||
t.Errorf("a host: %s", v)
|
||||
}
|
||||
if v := by["mail.compose.default_to"].Value; strings.Contains(v, "example") || strings.Contains(v, "someone") {
|
||||
t.Errorf("an address: %s", v)
|
||||
}
|
||||
if v := by["extensions.activeThemeID"].Value; v != `"thunderbird-compact-dark@mozilla.org"` {
|
||||
t.Errorf("a theme's id is answered as it is: %s", v)
|
||||
}
|
||||
if v := by["font.size.variable.x-western"].Value; v != "12" {
|
||||
t.Errorf("%s", v)
|
||||
}
|
||||
for _, bad := range [][]string{{""}, {"*"}, {"."}, {" "}} {
|
||||
if _, err := f.Prefs(bad); err == nil {
|
||||
t.Errorf("%q is refused: it asks for everything", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestALogLineKeepsNoBearerOrBasicCredential(t *testing.T) {
|
||||
for _, l := range []string{"Authorization: Bearer eyJhbGciOi.abc.def", "auth header basic dXNlcjpwYXNz sent", "token=abc123"} {
|
||||
got := mask(l)
|
||||
for _, secret := range []string{"eyJhbGciOi", "dXNlcjpwYXNz", "abc123"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("%q → %q", l, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.proc(6000, 1000, "betterbird-bin", []string{"/usr/bin/betterbird", "--no-remote", "--profile", "/run/user/1000/" + testUnit + "/profile"}, testUnit+".service")
|
||||
if ps := f.mainProcs(); len(ps) != 1 || ps[0].PID != 4000 {
|
||||
t.Fatalf("only the operator's: %+v", ps)
|
||||
}
|
||||
f.declare("// none")
|
||||
if _, err := f.ReminderTest(40*time.Second, nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit) {
|
||||
t.Fatalf("the unit removes its profile when it ends: %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "xwininfo" {
|
||||
return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
|
||||
t.Fatalf("a composer of another class is still a composer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.stubborn[4000] = true
|
||||
total := time.Duration(0)
|
||||
run := f.Run
|
||||
f.Run = func(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
// Every command takes its whole timeout: the worst case.
|
||||
if d, ok := ctx.Deadline(); ok {
|
||||
total += time.Until(d).Round(time.Second)
|
||||
}
|
||||
return run(ctx, env, name, args...)
|
||||
}
|
||||
f.Sleep = func(d time.Duration) { total += d }
|
||||
if _, err := f.Restart(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total > 26*time.Second {
|
||||
t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// No test reaches the network or the mesh: Betterbird's list of published builds and the controller's
|
||||
// settings verb answer from here, and a test that needs them says what they answer.
|
||||
func TestMain(m *testing.M) {
|
||||
publishedBuilds = func() (string, error) { return "", errors.New("no network in tests") }
|
||||
asker = func(string, any) (json.RawMessage, error) { return nil, errors.New("no mesh in tests") }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"accounts": {}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.
|
||||
#
|
||||
# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,
|
||||
# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do
|
||||
# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a
|
||||
# second start while one runs only hands over to it. What the preparation could not do it says here, and
|
||||
# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.
|
||||
/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2
|
||||
exec /opt/betterbird/betterbird "$@"
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"identities": {},
|
||||
"calendars": {},
|
||||
"address-books": {},
|
||||
"filters": {}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Betterbird
|
||||
GenericName=Mail Client
|
||||
Comment=Send and receive mail with Betterbird
|
||||
Exec=/usr/local/bin/betterbird %u
|
||||
Icon=/opt/betterbird/chrome/icons/default/default128.png
|
||||
Terminal=false
|
||||
Categories=Network;Email;
|
||||
MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;
|
||||
StartupNotify=true
|
||||
StartupWMClass=eu.betterbird.Betterbird
|
||||
Keywords=Email;E-mail;Calendar;Thunderbird;
|
||||
Actions=ComposeMessage;OpenAddressBook;
|
||||
# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:
|
||||
# it starts Betterbird through the module's start, and names the icon in the installation, with no link.
|
||||
|
||||
[Desktop Action ComposeMessage]
|
||||
Name=Write new message
|
||||
Exec=/usr/local/bin/betterbird -compose
|
||||
|
||||
[Desktop Action OpenAddressBook]
|
||||
Name=Open address book
|
||||
Exec=/usr/local/bin/betterbird -addressbook
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"prefs": {}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"tags": {}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module betterbird
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -1,2 +0,0 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -1,20 +0,0 @@
|
||||
// Written by the mesh (module betterbird, novox/hq issue 345, issue 586): the preferences the module manages at
|
||||
// Betterbird's own default. It holds no value of the operator's: the operator's setup is the module's setting
|
||||
// prefs, mesh-wide with a node's own layer over it, which the tools place in the profile's user.js, where it wins
|
||||
// at every start; the policy's locks (distribution/policies.json) win over everything. betterbird_config shows the
|
||||
// layers and the drift. Change it in the catalogue, never here.
|
||||
//
|
||||
// A line "keeps Betterbird's default" names a preference the module manages without writing a value: Betterbird's
|
||||
// own default holds there, and a value the profile saved is drift, which betterbird_config_apply clears.
|
||||
|
||||
// keeps Betterbird's default: extensions.activeThemeID
|
||||
// The default theme (default-theme@mozilla.org) follows the desktop's dark mode. Betterbird keeps the active theme
|
||||
// in its add-on database, so another theme is changed back in the add-on manager, never by clearing prefs.js.
|
||||
// keeps Betterbird's default: browser.display.use_system_colors
|
||||
// Betterbird's own colours, which the default theme is drawn for.
|
||||
// keeps Betterbird's default: layout.css.always_underline_links
|
||||
// Links are underlined only where the message's own style says so.
|
||||
// keeps Betterbird's default: mail.spam.manualMark
|
||||
// Marking a message as junk by hand does nothing more than mark it.
|
||||
// keeps Betterbird's default: mail.compose.show_attachment_pane
|
||||
// The composer shows its attachment pane only when a message has attachments.
|
||||
@@ -1,3 +0,0 @@
|
||||
// Written by the mesh (module betterbird, novox/hq ADR 0316 §3): the system's spelling dictionaries, named
|
||||
// as a default preference in place of the link the community package made.
|
||||
pref("spellchecker.dictionary_path", "/usr/share/hunspell");
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"policies": {
|
||||
"DisableAppUpdate": true,
|
||||
"DisableTelemetry": true,
|
||||
"ExtensionUpdate": false,
|
||||
"ExtensionSettings": {
|
||||
"owl@beonex.com": {
|
||||
"installation_mode": "force_installed",
|
||||
"install_url": "file:///opt/betterbird/owl/owl.xpi",
|
||||
"updates_disabled": true
|
||||
}
|
||||
},
|
||||
"Preferences": {
|
||||
"mail.shell.checkDefaultClient": {
|
||||
"Value": false,
|
||||
"Status": "locked"
|
||||
},
|
||||
"mail.inappnotifications.enabled": {
|
||||
"Value": false,
|
||||
"Status": "locked"
|
||||
},
|
||||
"mailnews.start_page.enabled": {
|
||||
"Value": false,
|
||||
"Status": "locked"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
{
|
||||
"module": "betterbird",
|
||||
"version": "1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)"
|
||||
},
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.settings",
|
||||
"seat:mesh-controller.nodes",
|
||||
"seat:mesh-controller.node",
|
||||
"betterbird.betterbird_derive"
|
||||
],
|
||||
"tools": [
|
||||
"betterbird_status",
|
||||
"betterbird_prefs",
|
||||
"betterbird_config",
|
||||
"betterbird_config_apply",
|
||||
"betterbird_user_js",
|
||||
"betterbird_log",
|
||||
"betterbird_restart",
|
||||
"betterbird_check",
|
||||
"betterbird_accounts",
|
||||
"betterbird_identities",
|
||||
"betterbird_calendars",
|
||||
"betterbird_address_books",
|
||||
"betterbird_filters",
|
||||
"betterbird_tags",
|
||||
"betterbird_derive",
|
||||
"betterbird_drift",
|
||||
"betterbird_reminder_test"
|
||||
],
|
||||
"config": {
|
||||
"prefs": {
|
||||
"type": "object",
|
||||
"description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"accounts": {
|
||||
"type": "object",
|
||||
"description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"identities": {
|
||||
"type": "object",
|
||||
"description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"calendars": {
|
||||
"type": "object",
|
||||
"description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"address-books": {
|
||||
"type": "object",
|
||||
"description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "The message filters of each account, by account: what each matches and what it does with the mail.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"tags": {
|
||||
"type": "object",
|
||||
"description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "configuration-dir",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/betterbird",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755",
|
||||
"unknown": "move-out",
|
||||
"knows": ["accounts-record.json", "configuration-record.json", "kept/"]
|
||||
},
|
||||
{
|
||||
"id": "prefs",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/prefs.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"prefs"
|
||||
],
|
||||
"template": "files/prefs.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "accounts",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/accounts.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"accounts"
|
||||
],
|
||||
"template": "files/accounts.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "configuration",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/configuration.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"identities",
|
||||
"calendars",
|
||||
"address-books",
|
||||
"filters"
|
||||
],
|
||||
"template": "files/configuration.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "tags",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/tags.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"tags"
|
||||
],
|
||||
"template": "files/tags.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "community-package",
|
||||
"type": "package",
|
||||
"package": "betterbird-bin",
|
||||
"absent": true
|
||||
},
|
||||
{
|
||||
"id": "installation",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird",
|
||||
"artifact": "archive",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "owl",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/owl",
|
||||
"artifact": "owl",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "policies",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/distribution",
|
||||
"artifact": "distribution",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "spelling",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/defaults/pref",
|
||||
"artifact": "defaults",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "preparation",
|
||||
"type": "archive",
|
||||
"path": "/usr/local/lib/mesh-betterbird",
|
||||
"artifact": "tools",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/betterbird",
|
||||
"mode": "0755",
|
||||
"template": "files/betterbird"
|
||||
},
|
||||
{
|
||||
"id": "launcher",
|
||||
"type": "file",
|
||||
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
|
||||
"mode": "0644",
|
||||
"template": "files/eu.betterbird.Betterbird.desktop"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/betterbird-tools",
|
||||
"binary": "betterbird-tools",
|
||||
"loads": [
|
||||
"betterbird-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "archive",
|
||||
"kind": "archive",
|
||||
"url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz",
|
||||
"sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2",
|
||||
"version": "153.4.0esr-bb10",
|
||||
"within": "betterbird",
|
||||
"names-on-purpose": {
|
||||
"www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "owl",
|
||||
"kind": "archive",
|
||||
"url": "https://www.beonex.com/owl/owl.xpi",
|
||||
"sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221",
|
||||
"version": "1.5.6.1",
|
||||
"file": "owl.xpi",
|
||||
"names-on-purpose": {
|
||||
"www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "distribution",
|
||||
"kind": "archive",
|
||||
"from": "install/distribution"
|
||||
},
|
||||
{
|
||||
"name": "defaults",
|
||||
"kind": "archive",
|
||||
"from": "install/defaults-pref"
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# buildah
|
||||
|
||||
The rootless image builder as a module (novox/hq ADR 0487, issue 1702).
|
||||
|
||||
- **The only module that declares the package `buildah`** (`checks/toolchains`), and the only one that provides
|
||||
`rootless-image-build` (`checks/imagebuild`; docker never does).
|
||||
- **Provides `rootless-image-build` with the machine's reach**: mesh-build-image requires it, so it composes only on a
|
||||
machine where this module is assigned, and a node without it is refused at resolution naming `buildah`.
|
||||
- **No account and no subordinate-id word.** The node-engine makes every `user` account with `useradd`, which
|
||||
allocates the account 65536 subordinate uids and gids; mesh-build-image's own account gets its ranges that way and
|
||||
its health proves them (it reads /etc/subuid and /etc/subgid).
|
||||
- The helpers (an OCI runtime, passt, containers-common) come as buildah's package dependencies.
|
||||
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"module": "buildah",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"user-namespaces"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "rootless-image-build",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "buildah",
|
||||
"type": "package",
|
||||
"package": "buildah"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -951,7 +951,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
|
||||
return "", nil
|
||||
}
|
||||
// Said to a proposer waiting on it only once the discard is kept: what it reads next is already there (the
|
||||
// flake of the router's refusal test under a loaded build agent, 2026-10-12).
|
||||
// flake of the router's refusal test under a loaded build node, 2026-10-12).
|
||||
tell := func() {
|
||||
endedMu.Lock()
|
||||
ended[w.Ask] = w
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
@@ -170,20 +171,33 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T)
|
||||
store := dir + "/cm/clipmenu.6.op"
|
||||
os.MkdirAll(store, 0o700)
|
||||
lock := store + "/session_lock"
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the
|
||||
// lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker
|
||||
// runs thousands of tests at once) must not let the launcher find the lock free (issue 1814).
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60")
|
||||
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
held, err := holder.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := holder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// flock's child shares the lock, so the whole group goes
|
||||
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
|
||||
defer release()
|
||||
for i := 0; i < 50; i++ {
|
||||
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
|
||||
break
|
||||
said := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := bufio.NewReader(held).ReadString('\n')
|
||||
said <- strings.TrimSpace(line)
|
||||
}()
|
||||
select {
|
||||
case line := <-said:
|
||||
if line != "held" {
|
||||
t.Fatalf("the older clipmenud did not take the lock: %q", line)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the older clipmenud did not take the lock within 30 s")
|
||||
}
|
||||
var out syncBuffer
|
||||
cmd := exec.Command("sh", file)
|
||||
|
||||
@@ -19,8 +19,8 @@ Assign it to every node, with the `go` and `nodejs` modules: it requires their p
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
|
||||
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
|
||||
pull request, and a local run is the developer's early answer, not the verdict.
|
||||
node. The versions are the distribution's: the build modules' toolchains, which the checker runs a pull request's tests with, stay the
|
||||
judge of a pull request, and a local run is the developer's early answer, not the verdict.
|
||||
|
||||
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
|
||||
file (`go.mod`, `package-lock.json`), as in the toolchain images.
|
||||
file (`go.mod`, `package-lock.json`), as in the build modules.
|
||||
|
||||
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
||||
|
||||
## Tools
|
||||
|
||||
All five only read.
|
||||
All six only read.
|
||||
|
||||
| tool | what | replaces |
|
||||
|---|---|---|
|
||||
@@ -32,6 +32,7 @@ All five only read.
|
||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
|
||||
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||
|
||||
### How each number is read
|
||||
@@ -87,4 +88,5 @@ cover:
|
||||
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
||||
- filesystems said once, mount escapes undone, a statfs that fails said;
|
||||
- the window's bounds;
|
||||
- folders ranked biggest first to the depth asked, a mount never entered, unreadable folders named, a walk past its bound answered as partial with where it stopped, a hard link counted once;
|
||||
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FolderBudget is how long a folder walk may take before it answers what it has measured.
|
||||
const FolderBudget = 25 * time.Second
|
||||
|
||||
// namedUnreadable is how many unreadable folders are named; the rest are only counted.
|
||||
const namedUnreadable = 50
|
||||
|
||||
// Folder is one folder's size: everything under it on the same filesystem.
|
||||
type Folder struct {
|
||||
Path string `json:"path"`
|
||||
Depth int `json:"depth"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
GiB float64 `json:"gib"`
|
||||
}
|
||||
|
||||
// FolderReport is the answer of disk_folders.
|
||||
type FolderReport struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
GiB float64 `json:"gib"`
|
||||
Largest []Folder `json:"largest"`
|
||||
Partial bool `json:"partial"`
|
||||
StoppedAt string `json:"stopped_at,omitempty"`
|
||||
Unreadable int `json:"unreadable"`
|
||||
UnreadableFolders []string `json:"unreadable_folders,omitempty"`
|
||||
Skipped []string `json:"other_filesystems_skipped,omitempty"`
|
||||
Said []string `json:"said"`
|
||||
}
|
||||
|
||||
func (m *Machine) readDir(real string) ([]os.DirEntry, error) {
|
||||
if m.ReadDir != nil {
|
||||
return m.ReadDir(real)
|
||||
}
|
||||
return os.ReadDir(real)
|
||||
}
|
||||
|
||||
func (m *Machine) now() time.Time {
|
||||
if m.Clock != nil {
|
||||
return m.Clock()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func devOf(path string, info fs.FileInfo) uint64 {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
return uint64(st.Dev)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type inodeKey struct{ dev, ino uint64 }
|
||||
|
||||
// walk is one folder measurement.
|
||||
type walk struct {
|
||||
m *Machine
|
||||
maxDepth int
|
||||
dev uint64
|
||||
deadline time.Time
|
||||
seen map[inodeKey]bool
|
||||
folders []Folder
|
||||
bad []string
|
||||
skipped []string
|
||||
stopped string
|
||||
}
|
||||
|
||||
// Folders measures path like du -x: allocated bytes, a hard link once, never entering another filesystem.
|
||||
func (m *Machine) Folders(path string, depth, limit int) (FolderReport, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return FolderReport{}, fmt.Errorf("path must be absolute, not %q", path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
info, err := os.Stat(m.path(path))
|
||||
if err != nil {
|
||||
return FolderReport{}, fmt.Errorf("path %s: %w", path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return FolderReport{}, fmt.Errorf("path %s is not a folder", path)
|
||||
}
|
||||
budget := m.Budget
|
||||
if budget == 0 {
|
||||
budget = FolderBudget
|
||||
}
|
||||
w := &walk{m: m, maxDepth: depth, deadline: m.now().Add(budget), seen: map[inodeKey]bool{}}
|
||||
w.dev = w.device(path, info)
|
||||
total := w.dir(path, 0)
|
||||
|
||||
sort.SliceStable(w.folders, func(i, j int) bool {
|
||||
if w.folders[i].Bytes != w.folders[j].Bytes {
|
||||
return w.folders[i].Bytes > w.folders[j].Bytes
|
||||
}
|
||||
return w.folders[i].Path < w.folders[j].Path
|
||||
})
|
||||
if len(w.folders) > limit {
|
||||
w.folders = w.folders[:limit]
|
||||
}
|
||||
sort.Strings(w.bad)
|
||||
named := w.bad
|
||||
if len(named) > namedUnreadable {
|
||||
named = named[:namedUnreadable]
|
||||
}
|
||||
r := FolderReport{
|
||||
Path: path, Bytes: total, GiB: gibOf(total), Largest: w.folders,
|
||||
Partial: w.stopped != "", StoppedAt: w.stopped,
|
||||
Unreadable: len(w.bad), UnreadableFolders: named, Skipped: w.skipped,
|
||||
}
|
||||
if r.Largest == nil {
|
||||
r.Largest = []Folder{}
|
||||
}
|
||||
var said []string
|
||||
if r.Partial {
|
||||
said = append(said, fmt.Sprintf("PARTIAL: the walk stopped after %s at %s; sizes are what was measured so far", budget, r.StoppedAt))
|
||||
}
|
||||
said = append(said, fmt.Sprintf("%s holds %.1f GiB on its own filesystem", path, r.GiB))
|
||||
if r.Unreadable > 0 {
|
||||
said = append(said, fmt.Sprintf("%d folders could not be read (%s), so their size is missing", r.Unreadable, strings.Join(named, ", ")))
|
||||
}
|
||||
if len(r.Skipped) > 0 {
|
||||
said = append(said, "other filesystems not entered: "+strings.Join(r.Skipped, ", "))
|
||||
}
|
||||
r.Said = said
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (w *walk) device(path string, info fs.FileInfo) uint64 {
|
||||
if w.m.DevOf != nil {
|
||||
return w.m.DevOf(path, info)
|
||||
}
|
||||
return devOf(path, info)
|
||||
}
|
||||
|
||||
// dir is the allocated bytes under a folder; it records the folder when it is within the depth.
|
||||
func (w *walk) dir(path string, depth int) int64 {
|
||||
if w.stopped != "" {
|
||||
return 0
|
||||
}
|
||||
if w.m.now().After(w.deadline) {
|
||||
w.stopped = path
|
||||
return 0
|
||||
}
|
||||
entries, err := w.m.readDir(w.m.path(path))
|
||||
if err != nil {
|
||||
w.bad = append(w.bad, path)
|
||||
return 0
|
||||
}
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
child := filepath.Join(path, e.Name())
|
||||
info, err := os.Lstat(w.m.path(child))
|
||||
if err != nil {
|
||||
continue // gone since it was listed
|
||||
}
|
||||
if info.IsDir() {
|
||||
if w.device(child, info) != w.dev {
|
||||
w.skipped = append(w.skipped, child)
|
||||
continue
|
||||
}
|
||||
total += w.allocated(info, false) + w.dir(child, depth+1)
|
||||
continue
|
||||
}
|
||||
total += w.allocated(info, true)
|
||||
}
|
||||
if depth >= 1 && depth <= w.maxDepth {
|
||||
w.folders = append(w.folders, Folder{Path: path, Depth: depth, Bytes: total, GiB: gibOf(total)})
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// allocated is the bytes an entry occupies on disk, as du counts them; a file with several links once.
|
||||
func (w *walk) allocated(info fs.FileInfo, file bool) int64 {
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return info.Size()
|
||||
}
|
||||
if file && st.Nlink > 1 {
|
||||
k := inodeKey{uint64(st.Dev), uint64(st.Ino)}
|
||||
if w.seen[k] {
|
||||
return 0
|
||||
}
|
||||
w.seen[k] = true
|
||||
}
|
||||
return st.Blocks * 512
|
||||
}
|
||||
|
||||
func gibOf(b int64) float64 { return math.Round(float64(b)/gib*100) / 100 }
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mb = 1 << 20
|
||||
|
||||
// megabytes writes a file of n MiB under the fake root.
|
||||
func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) }
|
||||
|
||||
func aFullDisk(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
f.megabytes("var/lib/docker/overlay2/a/layer", 8)
|
||||
f.megabytes("var/lib/docker/overlay2/b/layer", 4)
|
||||
f.megabytes("var/log/big.log", 3)
|
||||
f.megabytes("home/jo/video.mkv", 6)
|
||||
f.megabytes("home/jo/deep/er/est/file", 1)
|
||||
f.dir("empty")
|
||||
return f
|
||||
}
|
||||
|
||||
func folders(t *testing.T, m *Machine, args map[string]any) FolderReport {
|
||||
t.Helper()
|
||||
out, err := tool(t, m, "disk_folders").Run(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.(FolderReport)
|
||||
}
|
||||
|
||||
func paths(r FolderReport) string {
|
||||
var p []string
|
||||
for _, f := range r.Largest {
|
||||
p = append(p, f.Path)
|
||||
}
|
||||
return strings.Join(p, ",")
|
||||
}
|
||||
|
||||
// 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers
|
||||
// the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path.
|
||||
func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) {
|
||||
r := folders(t, aFullDisk(t).machine(), nil)
|
||||
if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") {
|
||||
t.Fatalf("ranked %s", got)
|
||||
}
|
||||
if r.Path != "/" || r.Partial || r.Unreadable != 0 {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
top := r.Largest[0]
|
||||
if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 {
|
||||
t.Errorf("top %+v", top)
|
||||
}
|
||||
for _, f := range r.Largest {
|
||||
if f.Depth > 2 || f.Depth < 1 {
|
||||
t.Errorf("%+v is outside depth 2", f)
|
||||
}
|
||||
if strings.Contains(f.Path, "overlay2") {
|
||||
t.Errorf("%s is deeper than 2", f.Path)
|
||||
}
|
||||
}
|
||||
if r.Bytes < 22*mb || r.Bytes > 24*mb {
|
||||
t.Errorf("total %d", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100.
|
||||
func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0})
|
||||
if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" {
|
||||
t.Errorf("%s: %s", r.Path, paths(r))
|
||||
}
|
||||
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0})
|
||||
if len(r.Largest) != 2 {
|
||||
t.Errorf("limit 2 gave %d", len(r.Largest))
|
||||
}
|
||||
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0})
|
||||
if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") {
|
||||
t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got)
|
||||
}
|
||||
tl := tool(t, f.machine(), "disk_folders")
|
||||
for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} {
|
||||
if _, err := tl.Run(bad); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 120; i++ {
|
||||
f.file(fmt.Sprintf("many/d%03d/f", i), "x")
|
||||
}
|
||||
if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 {
|
||||
t.Errorf("limit held at %d, not 100", len(r.Largest))
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC3: given a mount below the path, then it is never entered or counted, and is named.
|
||||
func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
m.DevOf = func(path string, info fs.FileInfo) uint64 {
|
||||
if path == "/var/lib/docker" {
|
||||
return 7
|
||||
}
|
||||
return 1
|
||||
}
|
||||
r := folders(t, m, nil)
|
||||
if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" {
|
||||
t.Errorf("%s, skipped %v", paths(r), r.Skipped)
|
||||
}
|
||||
if r.Bytes > 14*mb {
|
||||
t.Errorf("total %d includes the mount", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered.
|
||||
func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
real := os.ReadDir
|
||||
m.ReadDir = func(p string) ([]os.DirEntry, error) {
|
||||
if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") {
|
||||
return nil, fmt.Errorf("open %s: permission denied", p)
|
||||
}
|
||||
return real(p)
|
||||
}
|
||||
r := folders(t, m, map[string]any{"depth": 3.0})
|
||||
if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" {
|
||||
t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders)
|
||||
}
|
||||
if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped.
|
||||
func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
now := time.Unix(0, 0)
|
||||
m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now }
|
||||
r := folders(t, m, nil)
|
||||
if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC6: a hard link is counted once, and the tool changes nothing.
|
||||
func TestAHardLinkIsCountedOnce(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.megabytes("a/big", 5)
|
||||
if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
r := folders(t, f.machine(), nil)
|
||||
if r.Bytes > 6*mb {
|
||||
t.Errorf("counted twice: %d", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller.
|
||||
func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) {
|
||||
if FolderBudget >= 30*time.Second {
|
||||
t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -53,6 +54,13 @@ type Machine struct {
|
||||
Run Runner
|
||||
Wait func(time.Duration)
|
||||
Statfs func(path string) (FSStat, error)
|
||||
|
||||
// The folder walk's seams, all optional: the folder reader, the filesystem a path is on, the clock and
|
||||
// the time the walk may take (60 s when zero).
|
||||
ReadDir func(path string) ([]os.DirEntry, error)
|
||||
DevOf func(path string, info fs.FileInfo) uint64
|
||||
Clock func() time.Time
|
||||
Budget time.Duration
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user