Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe6ea4ee4 |
-17
@@ -7,23 +7,6 @@ modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
|
||||
modules/messenger/cmd/messenger/messenger
|
||||
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
|
||||
modules/claude-code/cmd/claude-code/claude-code
|
||||
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
|
||||
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
|
||||
modules/messenger/messenger
|
||||
modules/claude-code/claude-code
|
||||
modules/mesh-watcher/mesh-watcher
|
||||
modules/dnsmasq/dnsmasq
|
||||
modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
||||
modules/mounts/cmd/mounts/mounts
|
||||
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-reads/mesh-reads
|
||||
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||
modules/mesh-files/mesh-files
|
||||
modules/mesh-files/cmd/mesh-files/mesh-files
|
||||
|
||||
# The provisioner client built in place (issue 671).
|
||||
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Release 57: config and provisioning
|
||||
|
||||
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/agenthome
|
||||
|
||||
go 1.22
|
||||
@@ -1,128 +0,0 @@
|
||||
// Command agenthome refuses, in every module's manifest, a resource placed under the agent account's home
|
||||
// that the node-engine cannot write without a gap (novox/hq ADR 0266).
|
||||
//
|
||||
// The agent account owns its home and everything in it, so it can put a symbolic or hard link anywhere
|
||||
// there. The node-engine, as root, writes a whole file or a directory below a home through descriptors it
|
||||
// opened itself and refuses a link it finds; it unpacks an archive, and writes into an existing file (`into`:
|
||||
// json or block), only after checking the path, so a link swapped in between the check and the write would
|
||||
// point root's write at any file. No module does either today. This check keeps it so: a resource of type
|
||||
// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home} — written so, reached through
|
||||
// one of the module's own directories (${dir:<id>}), or written as a literal /home path — is refused. The
|
||||
// node-engine refuses the same below the home of any account declared never to become root, whatever the path.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentHome is the machine fact naming the agent account's home.
|
||||
const AgentHome = "${machine:agent-home}"
|
||||
|
||||
// ofDir is where a manifest names one of its own directories: ${dir:<id>}.
|
||||
var ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// resolve replaces each ${dir:<id>} with that resource's own path, as far as the manifest says it, so a path
|
||||
// reaching the agent's home through one of the module's directories is seen for what it is.
|
||||
func resolve(p string, dirs map[string]string) string {
|
||||
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
|
||||
p = ofDir.ReplaceAllStringFunc(p, func(word string) string {
|
||||
if to, ok := dirs[ofDir.FindStringSubmatch(word)[1]]; ok {
|
||||
return to
|
||||
}
|
||||
return word
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// underAHome says whether a resolved path lies under the agent's home: by its machine fact, or written as a
|
||||
// literal home, which no definition may carry anyway (ADR 0112) and which may well be the agent's.
|
||||
func underAHome(p string) bool {
|
||||
// Cleaned first, so //home/x or /./home/x is seen as the /home/x it is.
|
||||
if p != "" {
|
||||
p = filepath.Clean(p)
|
||||
}
|
||||
return strings.HasPrefix(p, AgentHome) || strings.HasPrefix(p, "/home/") || p == "/home"
|
||||
}
|
||||
|
||||
// Finding is one resource refused.
|
||||
type Finding struct {
|
||||
Module, ID, Why string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what 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"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if p, _ := r["path"].(string); id != "" && p != "" {
|
||||
dirs[id] = p
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
p, _ := r["path"].(string)
|
||||
if !underAHome(resolve(p, dirs)) {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
kind, _ := r["type"].(string)
|
||||
into, _ := r["into"].(string)
|
||||
switch {
|
||||
case kind == "archive":
|
||||
out = append(out, Finding{m.Module, id, "an archive unpacked under the agent's home"})
|
||||
case kind == "file" && into != "":
|
||||
out = append(out, Finding{m.Module, id, "a file written into (" + into + ") under the agent's home"})
|
||||
}
|
||||
}
|
||||
}
|
||||
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: %s is %s: the node-engine writes it as root after checking the path, and the agent "+
|
||||
"account could swap a link in between (novox/hq ADR 0266); place a whole file or a directory instead\n",
|
||||
f.Module, f.ID, f.Why)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("agent-home: no archive and no file written into under the agent's home")
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"theme","type":"archive","path":"${machine:agent-home}/.local/share/x"}`)
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","path":"${machine:agent-home}/.claude.json","into":"json","content":"{}"}`)
|
||||
manifest(t, root, "c", `{"id":"dir","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"whole","type":"file","path":"${machine:agent-home}/x","content":"y"},`+
|
||||
`{"id":"op","type":"archive","path":"${machine:account-home}/.local/share/x"}`)
|
||||
manifest(t, root, "d", `{"id":"home","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"inner","type":"directory","path":"${dir:home}/inner"},`+
|
||||
`{"id":"via-dir","type":"archive","path":"${dir:inner}/theme"}`)
|
||||
manifest(t, root, "e", `{"id":"literal","type":"file","path":"/home/agent/.bashrc","into":"block","content":"x"}`)
|
||||
manifest(t, root, "f", `{"id":"unclean","type":"archive","path":"//./home/agent/x"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 5 || found[0].Module != "a" || found[1].Module != "b" || found[2].ID != "via-dir" ||
|
||||
found[3].ID != "literal" || found[4].ID != "unclean" {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("%+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/config
|
||||
|
||||
go 1.22
|
||||
@@ -1,201 +0,0 @@
|
||||
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
|
||||
// clauses 1 and 11, design 57 package K6, issue 665).
|
||||
//
|
||||
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
|
||||
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
|
||||
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
|
||||
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
|
||||
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
|
||||
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
|
||||
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
|
||||
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
|
||||
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
|
||||
// while the trunk holds no copy, the modules in seed are the trunk's.
|
||||
// When it is empty, the controller refuses the old names itself (design 57 package R1).
|
||||
//
|
||||
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
|
||||
// rewording it would change what a node is sent.
|
||||
//
|
||||
// go run . [-base <the trunk's unmoved>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
|
||||
// string value is never followed by one.
|
||||
var (
|
||||
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
|
||||
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
|
||||
oldPlaceholder = "${setting:…}"
|
||||
oldTakes = "takes-settings"
|
||||
oldDeclaration = "settings"
|
||||
wholeLayer = "taking its whole layer"
|
||||
listFile = "unmoved"
|
||||
listPathInCatalog = "checks/config/" + listFile
|
||||
)
|
||||
|
||||
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
|
||||
func Check(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
out := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
var said []string
|
||||
if len(m.Settings) > 0 && string(m.Settings) != "null" {
|
||||
said = append(said, oldDeclaration)
|
||||
}
|
||||
if takesSettingsKey.Match(raw) {
|
||||
said = append(said, oldTakes)
|
||||
}
|
||||
if settingRef.Match(raw) {
|
||||
said = append(said, oldPlaceholder)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
|
||||
if how, _ := r["merge"].(string); how != "" {
|
||||
_, oldList := r["takes-settings"]
|
||||
_, newList := r["takes-config"]
|
||||
if !oldList && !newList {
|
||||
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
|
||||
}
|
||||
}
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
|
||||
}
|
||||
if settingRef.Match(text) {
|
||||
said = append(said, oldPlaceholder+" in "+name)
|
||||
}
|
||||
}
|
||||
if len(said) > 0 {
|
||||
sort.Strings(said)
|
||||
out[m.Module] = said
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
|
||||
// (issue 735), so they stand in it too.
|
||||
var seed = map[string]bool{
|
||||
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
||||
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
|
||||
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
|
||||
}
|
||||
|
||||
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||
func Unmoved(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[line] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
|
||||
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
|
||||
for m := range found {
|
||||
if !unmoved[m] {
|
||||
refused = append(refused, m)
|
||||
}
|
||||
}
|
||||
for m := range unmoved {
|
||||
if _, says := found[m]; !says {
|
||||
stale = append(stale, m)
|
||||
}
|
||||
if !base[m] {
|
||||
added = append(added, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(refused)
|
||||
sort.Strings(stale)
|
||||
sort.Strings(added)
|
||||
return refused, stale, added
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
unmoved, err := Unmoved(listFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Unmoved(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
refused, stale, added := Judge(found, unmoved, base)
|
||||
for _, m := range refused {
|
||||
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
|
||||
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
|
||||
}
|
||||
for _, m := range stale {
|
||||
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
|
||||
}
|
||||
for _, m := range added {
|
||||
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
|
||||
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
|
||||
"(%d not yet moved)\n",
|
||||
len(unmoved))
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func module(t *testing.T, root, name, body string, files map[string]string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, text := range files {
|
||||
full := filepath.Join(dir, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
|
||||
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
|
||||
func TestEveryOldNameIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
|
||||
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
|
||||
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
|
||||
`"takes-settings":["a"],"content":"{}"}]}`, nil)
|
||||
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
|
||||
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
|
||||
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
|
||||
map[string]string{"files/conf": "size = ${setting:size}\n"})
|
||||
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
|
||||
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
|
||||
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
|
||||
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
|
||||
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{
|
||||
"asks": {"${setting:…}"},
|
||||
"declares": {"${setting:…}", "settings"},
|
||||
"takes": {"takes-settings"},
|
||||
"templated": {"${setting:…} in files/conf"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
|
||||
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
|
||||
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
|
||||
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
|
||||
unmoved := map[string]bool{"old": true, "done": true, "new": true}
|
||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||
refused, stale, added := Judge(found, unmoved, base)
|
||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||
!reflect.DeepEqual(added, []string{"new"}) {
|
||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unmoved")
|
||||
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Unmoved(path)
|
||||
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
|
||||
// still say settings.
|
||||
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
|
||||
found, err := Check(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmoved, err := Unmoved("unmoved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale, added := Judge(found, unmoved, seed)
|
||||
for _, m := range refused {
|
||||
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
|
||||
}
|
||||
for _, m := range stale {
|
||||
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
|
||||
}
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
|
||||
}
|
||||
}
|
||||
|
||||
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
|
||||
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
|
||||
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
|
||||
// included, is not.
|
||||
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
|
||||
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
|
||||
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
|
||||
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
|
||||
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
|
||||
`"takes-config":[],"content":"{}"}]}`, nil)
|
||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
|
||||
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
|
||||
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
|
||||
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
|
||||
# refuses the old names (design 57 package R1).
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/deps
|
||||
|
||||
go 1.22
|
||||
@@ -1,255 +0,0 @@
|
||||
// Command deps makes a module's dependencies available to the catalogue's check, or says why it cannot
|
||||
// (novox/hq issue 385).
|
||||
//
|
||||
// A module's tests run only when its dependencies can be fetched. The build seat clones what a check
|
||||
// needs with the forge credential and forgets that credential before the check runs, so a module pinning
|
||||
// an SDK commit the seat's module cache does not hold cannot fetch it from the forge. The SDK is cloned
|
||||
// beside the check all the same (MESH_CHECK_BESIDE, mesh-controller's besideRefs): every requirement of the
|
||||
// mesh's own forge is taken from the clone of its repository there, at the commit or the tag its version
|
||||
// names, through a go.mod of its own (-modfile) — the module's own files are not changed, and no forge
|
||||
// credential is added.
|
||||
//
|
||||
// Usage: deps -beside <dir> -work <dir> <module dir>. It prints the go.mod to build the module with, or
|
||||
// nothing when the module fetches what it needs; when it cannot, it prints why and exits 1, and the check
|
||||
// fails: a module whose tests could not run is a failure, never a note.
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the prefix of the Go modules of the mesh's own forge: <forge><repository>[/<directory>].
|
||||
const forge = "git.novox.be/novox/"
|
||||
|
||||
func main() {
|
||||
beside := flag.String("beside", "", "where the repositories beside the check are cloned (MESH_CHECK_BESIDE)")
|
||||
work := flag.String("work", os.TempDir(), "where the go.mod and the sources taken from beside are written")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fmt.Println("usage: deps -beside <dir> -work <dir> <module dir>")
|
||||
os.Exit(2)
|
||||
}
|
||||
modfile, err := resolve(flag.Arg(0), *beside, *work)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if modfile != "" {
|
||||
fmt.Println(modfile)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve is the go.mod the module at dir builds with: "" when its own fetches everything, else one
|
||||
// beside it in work that takes every requirement of the mesh's forge from its clone beside the check.
|
||||
func resolve(dir, beside, work string) (string, error) {
|
||||
out, err := goIn(dir, "mod", "download")
|
||||
if err == nil {
|
||||
return "", nil
|
||||
}
|
||||
fetching := lastLine(out)
|
||||
required, err := forgeRequirements(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(required) == 0 {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched: %s", fetching)
|
||||
}
|
||||
if beside == "" {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and nothing was cloned beside the check to "+
|
||||
"take %s from", fetching, repositories(required))
|
||||
}
|
||||
into, err := os.MkdirTemp(work, filepath.Base(dir)+"-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
modfile := filepath.Join(into, "go.mod")
|
||||
if err := copyFile(filepath.Join(dir, "go.mod"), modfile); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyFile(filepath.Join(dir, "go.sum"), filepath.Join(into, "go.sum")); err != nil &&
|
||||
!errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
for _, r := range required {
|
||||
source, err := export(beside, r, into)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and %s@%s cannot be taken from the "+
|
||||
"clone beside the check: %w", fetching, r.Path, r.Version, err)
|
||||
}
|
||||
if out, err := goIn(dir, "mod", "edit", "-replace", r.Path+"@"+r.Version+"="+source, modfile); err != nil {
|
||||
return "", fmt.Errorf("%s@%s cannot be taken from the clone beside the check: %s", r.Path, r.Version,
|
||||
lastLine(out))
|
||||
}
|
||||
}
|
||||
if out, err := goIn(dir, "mod", "download", "-modfile="+modfile); err != nil {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched even with %s taken from the clone beside the "+
|
||||
"check: %s", repositories(required), lastLine(out))
|
||||
}
|
||||
return modfile, nil
|
||||
}
|
||||
|
||||
// requirement is one module the module requires.
|
||||
type requirement struct {
|
||||
Path string
|
||||
Version string
|
||||
}
|
||||
|
||||
// forgeRequirements is every requirement of the module at dir that comes from the mesh's own forge.
|
||||
func forgeRequirements(dir string) ([]requirement, error) {
|
||||
out, err := goIn(dir, "mod", "edit", "-json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
|
||||
}
|
||||
var mod struct{ Require []requirement }
|
||||
if err := json.Unmarshal(out, &mod); err != nil {
|
||||
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
|
||||
}
|
||||
var on []requirement
|
||||
for _, r := range mod.Require {
|
||||
if strings.HasPrefix(r.Path, forge) {
|
||||
on = append(on, r)
|
||||
}
|
||||
}
|
||||
return on, nil
|
||||
}
|
||||
|
||||
// repositoryOf splits a module path of the forge into its repository and the directory in it.
|
||||
func repositoryOf(path string) (string, string) {
|
||||
repo, sub, _ := strings.Cut(strings.TrimPrefix(path, forge), "/")
|
||||
return repo, sub
|
||||
}
|
||||
|
||||
func repositories(rs []requirement) string {
|
||||
var names []string
|
||||
for _, r := range rs {
|
||||
repo, _ := repositoryOf(r.Path)
|
||||
names = append(names, repo)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// pseudo is a pseudo-version's commit: v0.1.14-0.20261012082113-9747f3e1b1ac → 9747f3e1b1ac.
|
||||
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})$`)
|
||||
|
||||
// refOf is the ref a version names in its repository: a pseudo-version's commit, else the tag a module in
|
||||
// a directory is released under (<directory>/<version>).
|
||||
func refOf(sub, version string) string {
|
||||
version = strings.TrimSuffix(version, "+incompatible")
|
||||
if m := pseudo.FindStringSubmatch(version); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if sub != "" {
|
||||
return sub + "/" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// export writes the module r names, at its version, from its repository's clone beside the check into
|
||||
// into, and answers its directory.
|
||||
func export(beside string, r requirement, into string) (string, error) {
|
||||
repo, sub := repositoryOf(r.Path)
|
||||
clone := filepath.Join(beside, repo)
|
||||
if _, err := os.Stat(filepath.Join(clone, ".git")); err != nil {
|
||||
return "", fmt.Errorf("no clone of %s is beside the check", repo)
|
||||
}
|
||||
ref := refOf(sub, r.Version)
|
||||
commit, err := exec.Command("git", "-C", clone, "rev-parse", "--verify", "--quiet", ref+"^{commit}").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the clone of %s holds no %s (a commit or tag not pushed to the forge?)", repo, ref)
|
||||
}
|
||||
at := strings.TrimSpace(string(commit))
|
||||
args := []string{"-C", clone, "archive", "--format=tar", at}
|
||||
if sub != "" {
|
||||
args = append(args, "--", sub)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
archive := exec.Command("git", args...)
|
||||
archive.Stderr = &stderr
|
||||
raw, err := archive.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git archive of %s at %s: %v %s", repo, ref, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
root := filepath.Join(into, repo+"@"+at[:12])
|
||||
if err := untar(raw, root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
source := filepath.Join(root, filepath.FromSlash(sub))
|
||||
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
|
||||
return "", fmt.Errorf("%s at %s has no go.mod in %q", repo, ref, sub)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// untar writes a tar's directories and regular files under root.
|
||||
func untar(raw []byte, root string) error {
|
||||
r := tar.NewReader(bytes.NewReader(raw))
|
||||
for {
|
||||
h, err := r.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.Clean(filepath.FromSlash(h.Name))
|
||||
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(from, to string) error {
|
||||
raw, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(to, raw, 0o644)
|
||||
}
|
||||
|
||||
// goIn runs the go command in dir.
|
||||
func goIn(dir string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = dir
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return append(out, stderr.Bytes()...), err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lastLine is the last line a command said, which is where go says what failed.
|
||||
func lastLine(out []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// offline is a Go that fetches nothing: what the build seat is once the forge credential is gone, for a pin
|
||||
// its module cache does not hold (novox/hq issue 385).
|
||||
func offline(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GOPROXY", "off")
|
||||
t.Setenv("GOFLAGS", "")
|
||||
t.Setenv("GOWORK", "off")
|
||||
// Not GOPRIVATE: a private module is fetched directly by git, around GOPROXY, with whatever credential
|
||||
// the machine running the test holds.
|
||||
t.Setenv("GOPRIVATE", "")
|
||||
t.Setenv("GONOPROXY", "")
|
||||
t.Setenv("GOSUMDB", "off")
|
||||
t.Setenv("GOMODCACHE", t.TempDir())
|
||||
}
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
|
||||
"GIT_COMMITTER_EMAIL=t@t", "GIT_AUTHOR_DATE=2026-10-12T08:21:13Z", "GIT_COMMITTER_DATE=2026-10-12T08:21:13Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// sdkBeside is the SDK's repository cloned beside a check, its Go module under go/, one commit tagged
|
||||
// go/v0.1.7. It answers the beside root and the commit.
|
||||
func sdkBeside(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
beside := t.TempDir()
|
||||
repo := filepath.Join(beside, "mesh-sdk")
|
||||
write(t, filepath.Join(repo, "go", "go.mod"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.22\n")
|
||||
write(t, filepath.Join(repo, "go", "sdk", "sdk.go"), "package sdk\n\nfunc Hello() string { return \"hello\" }\n")
|
||||
write(t, filepath.Join(repo, "README.md"), "the SDK\n")
|
||||
git(t, repo, "init", "--quiet")
|
||||
git(t, repo, "add", ".")
|
||||
git(t, repo, "commit", "--quiet", "-m", "sdk")
|
||||
git(t, repo, "tag", "go/v0.1.7")
|
||||
return beside, git(t, repo, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// moduleRequiring is a catalogue module whose code and test use the SDK at the version given.
|
||||
func moduleRequiring(t *testing.T, version string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "messenger")
|
||||
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/messenger\n\ngo 1.22\n\n"+
|
||||
"require git.novox.be/novox/mesh-sdk/go "+version+"\n")
|
||||
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\n"+
|
||||
"func main() { println(sdk.Hello()) }\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
func builds(t *testing.T, dir, modfile string) {
|
||||
t.Helper()
|
||||
args := []string{"vet"}
|
||||
if modfile != "" {
|
||||
args = append(args, "-modfile="+modfile)
|
||||
}
|
||||
cmd := exec.Command("go", append(args, "./...")...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("the module does not build with %q: %v\n%s", modfile, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A module that needs nothing it cannot fetch is left as it is: no other go.mod.
|
||||
func TestAModuleThatFetchesItsDependenciesNeedsNothingBeside(t *testing.T) {
|
||||
offline(t)
|
||||
dir := filepath.Join(t.TempDir(), "plain")
|
||||
write(t, filepath.Join(dir, "go.mod"), "module example.com/plain\n\ngo 1.22\n")
|
||||
write(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n")
|
||||
modfile, err := resolve(dir, "", t.TempDir())
|
||||
if err != nil || modfile != "" {
|
||||
t.Fatalf("resolve = %q, %v; want nothing", modfile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The case of issue 385: the module pins an SDK commit the build seat cannot fetch. It is taken from the
|
||||
// SDK's clone beside the check, and the module builds against it.
|
||||
func TestAPinnedSDKCommitIsTakenFromTheCloneBeside(t *testing.T) {
|
||||
offline(t)
|
||||
beside, commit := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
|
||||
modfile, err := resolve(dir, beside, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if modfile == "" {
|
||||
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch them from")
|
||||
}
|
||||
builds(t, dir, modfile)
|
||||
if raw, _ := os.ReadFile(filepath.Join(dir, "go.mod")); strings.Contains(string(raw), "replace") {
|
||||
t.Fatalf("the module's own go.mod was changed:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A released SDK is found by the tag it is released under: go/<version>.
|
||||
func TestATaggedSDKIsTakenFromTheCloneBeside(t *testing.T) {
|
||||
offline(t)
|
||||
beside, _ := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.7")
|
||||
modfile, err := resolve(dir, beside, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
builds(t, dir, modfile)
|
||||
}
|
||||
|
||||
// A commit the clone beside does not hold (a branch never pushed) cannot be tested, and says which.
|
||||
func TestACommitTheCloneBesideDoesNotHoldIsSaid(t *testing.T) {
|
||||
offline(t)
|
||||
beside, _ := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261010132341-4f3efc98e0c0")
|
||||
_, err := resolve(dir, beside, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("resolve found a commit nothing holds")
|
||||
}
|
||||
for _, want := range []string{"git.novox.be/novox/mesh-sdk/go@v0.1.14-0.20261010132341-4f3efc98e0c0", "4f3efc98e0c0"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the reason %q does not name %s", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing cloned beside (a check by hand without the forge), the reason says that.
|
||||
func TestNoCloneBesideIsSaid(t *testing.T) {
|
||||
offline(t)
|
||||
_, commit := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
|
||||
for _, beside := range []string{"", t.TempDir()} {
|
||||
_, err := resolve(dir, beside, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatalf("resolve with %q beside found what is not there", beside)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-sdk") {
|
||||
t.Errorf("the reason %q does not name the repository it needed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRefOfAVersion(t *testing.T) {
|
||||
cases := []struct{ sub, version, want string }{
|
||||
{"go", "v0.1.14-0.20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
|
||||
{"go", "v0.0.0-20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
|
||||
{"go", "v0.1.7", "go/v0.1.7"},
|
||||
{"", "v1.2.0", "v1.2.0"},
|
||||
{"", "v2.0.0+incompatible", "v2.0.0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := refOf(c.sub, c.version); got != c.want {
|
||||
t.Errorf("refOf(%q, %q) = %q, want %q", c.sub, c.version, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
||||
|
||||
go 1.22
|
||||
@@ -1,81 +0,0 @@
|
||||
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
|
||||
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
|
||||
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// MailStoreSeat is the seat only mesh-mail may claim.
|
||||
const MailStoreSeat = "mail-store"
|
||||
|
||||
// MailStoreModule is the one module allowed to claim it.
|
||||
const MailStoreModule = "mesh-mail"
|
||||
|
||||
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
|
||||
type Finding struct {
|
||||
Module string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which ones it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if m.Module == MailStoreModule {
|
||||
continue
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == MailStoreSeat {
|
||||
out = append(out, Finding{Module: m.Module})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("mail-store: claimed only by mesh-mail")
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, claims string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","claims":[` + claims + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
|
||||
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
|
||||
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "betterbird" {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("%+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/owned
|
||||
|
||||
go 1.22
|
||||
@@ -1,242 +0,0 @@
|
||||
// Command owned judges how the catalogue's directories and archives treat files they did not write (novox/hq
|
||||
// ADR 0422 §8, issue 1024). It refuses, naming the module, the resource and the reason:
|
||||
//
|
||||
// 1. a directory or archive without `unknown`, or with a value but move-out, module or program;
|
||||
//
|
||||
// 2. `move-out` on a path not under ${machine:account-home} or ${machine:agent-home};
|
||||
//
|
||||
// 3. `knows` with an empty, absolute, `.`/`..`/empty-component or repeated path;
|
||||
//
|
||||
// 4. a path one module declares (a resource's path, or a fact with "home": true) lying inside a directory of
|
||||
// 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>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Home is the one root both machine homes are read as.
|
||||
const Home = "${home}"
|
||||
|
||||
var (
|
||||
ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
homeVar = strings.NewReplacer("${machine:account-home}", Home, "${machine:agent-home}", Home)
|
||||
values = map[string]bool{"move-out": true, "module": true, "program": true}
|
||||
)
|
||||
|
||||
// Finding is one refusal.
|
||||
type Finding struct {
|
||||
Rule int
|
||||
Module, ID, Why string
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID, Type, Path, Unknown string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
}
|
||||
|
||||
type parsed struct {
|
||||
Module string
|
||||
Resources []resource
|
||||
Facts []string // paths of facts with "home": true, under the home
|
||||
}
|
||||
|
||||
func load(file string) (*parsed, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Unknown *string `json:"unknown"`
|
||||
Knows []string `json:"knows"`
|
||||
} `json:"resources"`
|
||||
Facts map[string]struct {
|
||||
Path string `json:"path"`
|
||||
Home bool `json:"home"`
|
||||
} `json:"facts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", file, err)
|
||||
}
|
||||
out := &parsed{Module: m.Module}
|
||||
if out.Module == "" {
|
||||
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}
|
||||
if r.Unknown != nil {
|
||||
x.Unknown = *r.Unknown
|
||||
if x.Unknown == "" {
|
||||
x.Unknown = "(empty)"
|
||||
}
|
||||
}
|
||||
out.Resources = append(out.Resources, x)
|
||||
}
|
||||
keys := make([]string, 0, len(m.Facts))
|
||||
for k := range m.Facts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if f := m.Facts[k]; f.Home && f.Path != "" {
|
||||
out.Facts = append(out.Facts, f.Path)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// norm resolves ${dir:<id>} from the module's own resources, reads both homes as one root and cleans the path.
|
||||
func norm(p string, dirs map[string]string) string {
|
||||
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
|
||||
p = ofDir.ReplaceAllStringFunc(p, func(w string) string {
|
||||
if to, ok := dirs[ofDir.FindStringSubmatch(w)[1]]; ok {
|
||||
return to
|
||||
}
|
||||
return w
|
||||
})
|
||||
}
|
||||
p = homeVar.Replace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Clean(p)
|
||||
}
|
||||
|
||||
func underHome(p string) bool { return strings.HasPrefix(p, Home+"/") }
|
||||
|
||||
// badKnows says why a knows entry is refused, or "".
|
||||
func badKnows(k string) string {
|
||||
if k == "" {
|
||||
return "is empty"
|
||||
}
|
||||
if strings.HasPrefix(k, "/") {
|
||||
return "is absolute"
|
||||
}
|
||||
for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") {
|
||||
if c == "" || c == "." || c == ".." {
|
||||
return "has an empty, . or .. component"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type declared struct {
|
||||
module, id, path string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
var out []Finding
|
||||
var paths []declared
|
||||
type container struct{ module, path string }
|
||||
var containers []container
|
||||
for _, file := range files {
|
||||
m, err := load(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
switch {
|
||||
case r.ID != "" && r.Path != "":
|
||||
dirs[r.ID] = r.Path
|
||||
case r.ID != "" && r.Type == "directory":
|
||||
// A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is
|
||||
// the module's own, so a path under it belongs to this module and to no other's directory of the same
|
||||
// id (issue 1484: two runs-as modules each keep a .ssh in their own state directory).
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
p := norm(r.Path, dirs)
|
||||
if p != "" {
|
||||
paths = append(paths, declared{m.Module, r.ID, p})
|
||||
}
|
||||
if r.Type != "directory" && r.Type != "archive" {
|
||||
continue
|
||||
}
|
||||
if r.Type == "directory" && p != "" {
|
||||
containers = append(containers, container{m.Module, p})
|
||||
}
|
||||
add := func(rule int, why string) { out = append(out, Finding{rule, m.Module, r.ID, why}) }
|
||||
switch {
|
||||
case r.Unknown == "":
|
||||
add(1, "a "+r.Type+" without `unknown` (move-out, module or program)")
|
||||
case !values[r.Unknown]:
|
||||
add(1, fmt.Sprintf("`unknown` is %q, not move-out, module or program", r.Unknown))
|
||||
case r.Unknown == "move-out" && !underHome(p):
|
||||
add(2, fmt.Sprintf("`move-out` on %q, which is not under ${machine:account-home} or ${machine:agent-home}", r.Path))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range r.Knows {
|
||||
if why := badKnows(k); why != "" {
|
||||
add(3, fmt.Sprintf("`knows` entry %q %s", k, why))
|
||||
continue
|
||||
}
|
||||
c := strings.TrimSuffix(k, "/")
|
||||
if seen[c] {
|
||||
add(3, fmt.Sprintf("`knows` entry %q is repeated", k))
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
if r.HasKnows && len(r.Knows) == 0 {
|
||||
add(3, "`knows` is empty")
|
||||
}
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
if p := norm(Home+"/"+f, nil); p != "" {
|
||||
paths = append(paths, declared{m.Module, "fact " + f, p})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range paths {
|
||||
for _, c := range containers {
|
||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
||||
out = append(out, Finding{4, d.module, d.id, fmt.Sprintf("declares %q, inside a directory of %s (%q)", d.path, c.module, c.path)})
|
||||
}
|
||||
}
|
||||
}
|
||||
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: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, body string) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func check(t *testing.T, root string) []Finding {
|
||||
t.Helper()
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// only asserts exactly one finding, of the rule, naming the module and the resource (issue 1024).
|
||||
func only(t *testing.T, found []Finding, rule int, module, id string) {
|
||||
t.Helper()
|
||||
if len(found) != 1 || found[0].Rule != rule || found[0].Module != module || found[0].ID != id || found[0].Why == "" {
|
||||
t.Fatalf("found %+v; want one finding of rule %d for %s/%s with a reason", found, rule, module, id)
|
||||
}
|
||||
}
|
||||
|
||||
func dir(id, path, rest string) string {
|
||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||
}
|
||||
|
||||
func mod(name string, resources ...string) string {
|
||||
return `{"module":"` + name + `","resources":[` + strings.Join(resources, ",") + `]}`
|
||||
}
|
||||
|
||||
func TestMissingOrUnknownValueIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "/var/lib/a", "")))
|
||||
only(t, check(t, root), 1, "a", "d")
|
||||
|
||||
root = t.TempDir()
|
||||
manifest(t, root, "a", mod("a", `{"id":"z","type":"archive","path":"/opt/a","unknown":"delete"}`))
|
||||
only(t, check(t, root), 1, "a", "z")
|
||||
}
|
||||
|
||||
func TestMoveOutOutsideAHomeIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "/etc/a.d", `,"unknown":"move-out"`)))
|
||||
only(t, check(t, root), 2, "a", "d")
|
||||
}
|
||||
|
||||
func TestMoveOutUnderAHomeIsAllowed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a",
|
||||
dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out"`),
|
||||
dir("e", "${dir:d}/sub", `,"unknown":"move-out"`),
|
||||
dir("f", "${machine:agent-home}/x", `,"unknown":"move-out"`),
|
||||
dir("g", "/var/lib/a", `,"unknown":"program"`),
|
||||
dir("h", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadKnowsIsRefused(t *testing.T) {
|
||||
for _, knows := range []string{`[""]`, `["/abs"]`, `["a/../b"]`, `["./a"]`, `["a//b"]`, `["a","a"]`, `["a","a/"]`} {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":`+knows)))
|
||||
only(t, check(t, root), 3, "a", "d")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":["forge.credentials","kept/"]`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModulesPathInsideADirectoryIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "owner", mod("owner", dir("ssh", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
|
||||
manifest(t, root, "intruder", mod("intruder", `{"id":"f","type":"file","path":"${machine:account-home}/.ssh/config.d/20"}`))
|
||||
manifest(t, root, "viahome", mod("viahome", `{"id":"g","type":"file","path":"${machine:agent-home}/.ssh/x"}`))
|
||||
manifest(t, root, "facty", `{"module":"facty","facts":{"hosts":{"path":".ssh/config.d/00","home":true}}}`)
|
||||
manifest(t, root, "outside", mod("outside", `{"id":"o","type":"file","path":"${machine:account-home}/.sshrc"}`))
|
||||
got := map[string]int{}
|
||||
for _, f := range check(t, root) {
|
||||
if f.Rule != 4 || f.Why == "" {
|
||||
t.Fatalf("finding %+v", f)
|
||||
}
|
||||
got[f.Module]++
|
||||
}
|
||||
if len(got) != 3 || got["intruder"] != 1 || got["viahome"] != 1 || got["facty"] != 1 {
|
||||
t.Fatalf("refused %v; want intruder, viahome, facty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirReferencesAreResolvedPerModule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "owner", mod("owner", dir("base", "${machine:account-home}/.config/o", `,"unknown":"move-out"`)))
|
||||
manifest(t, root, "other", mod("other",
|
||||
dir("base", "${machine:account-home}/.config/p", `,"unknown":"move-out"`),
|
||||
`{"id":"f","type":"file","path":"${dir:base}/x"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
manifest(t, root, "third", mod("third",
|
||||
dir("d", "${machine:account-home}/.config/t", `,"unknown":"move-out"`),
|
||||
`{"id":"f","type":"file","path":"${dir:d}/../o/x"}`))
|
||||
only(t, check(t, root), 4, "third", "f")
|
||||
}
|
||||
|
||||
func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a",
|
||||
`{"id":"arc","type":"archive","path":"/opt/a","unknown":"program"}`,
|
||||
dir("d", "/opt/a2", `,"unknown":"program"`),
|
||||
`{"id":"f","type":"file","path":"/opt/a2/x"}`))
|
||||
manifest(t, root, "b", mod("b", `{"id":"f","type":"file","path":"/opt/a/x"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's
|
||||
// directory of the same id, and a path under it, are not inside it.
|
||||
func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"first", "second"} {
|
||||
manifest(t, root, name, mod(name,
|
||||
`{"id":"state","type":"directory","place":".","unknown":"program"}`,
|
||||
dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`),
|
||||
`{"id":"conf","type":"file","path":"${dir:ssh}/config"}`))
|
||||
}
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||
|
||||
go 1.22
|
||||
@@ -1,153 +0,0 @@
|
||||
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
||||
// 0348 clause 7, design 57 package K2, issue 661).
|
||||
//
|
||||
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
||||
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
||||
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
||||
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
||||
// it, naming each module that names the key.
|
||||
//
|
||||
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
||||
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
||||
// itself, removing a key nobody names is let through here, and said.
|
||||
//
|
||||
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
||||
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
||||
// module check; this check reads only its keys.
|
||||
//
|
||||
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// File is the list's file at the root of the catalogue.
|
||||
const File = "predefined.json"
|
||||
|
||||
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
||||
func Keys(path string) (map[string]bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for k := range keys {
|
||||
out[k] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
||||
func Named(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
named := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Config map[string]json.RawMessage `json:"config"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
||||
for _, d := range declared {
|
||||
var value struct {
|
||||
Predefined string `json:"predefined"`
|
||||
}
|
||||
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
||||
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return named, nil
|
||||
}
|
||||
|
||||
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
||||
// key nobody names is answered with none.
|
||||
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for k := range base {
|
||||
if !tree[k] {
|
||||
out[k] = named[k]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
root := flag.Arg(0)
|
||||
tree, err := Keys(filepath.Join(root, File))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *basePath == "" {
|
||||
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
||||
return
|
||||
}
|
||||
base, err := Keys(*basePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
removed := Removed(base, tree, named)
|
||||
keys := make([]string, 0, len(removed))
|
||||
for k := range removed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
failed := false
|
||||
for _, k := range keys {
|
||||
if modules := removed[k]; len(modules) > 0 {
|
||||
failed = true
|
||||
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
||||
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
||||
} else {
|
||||
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
||||
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
||||
// agent-managed-settings.
|
||||
func aCatalogue(t *testing.T, list string) (root, base string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
write := func(path, text string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
||||
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
||||
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
||||
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
||||
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
||||
if list != "" {
|
||||
write(filepath.Join(root, File), list)
|
||||
}
|
||||
base = filepath.Join(t.TempDir(), "base.json")
|
||||
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
||||
return root, base
|
||||
}
|
||||
|
||||
func run(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
||||
// The planted removal: font-size goes while dunst still falls back to it.
|
||||
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("a removal of a named key passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
||||
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
||||
!strings.Contains(out, "mesh-config's to refuse") {
|
||||
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
||||
for _, list := range []string{
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
||||
} {
|
||||
root, base := aCatalogue(t, list)
|
||||
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
||||
t.Fatalf("%s: %v\n%s", list, err, out)
|
||||
}
|
||||
}
|
||||
// No trunk list given: nothing was removed.
|
||||
root, _ := aCatalogue(t, `{}`)
|
||||
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
||||
t.Fatalf("without a base: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
||||
root, base := aCatalogue(t, "")
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("the list removed whole passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedReadsBothForms(t *testing.T) {
|
||||
root, _ := aCatalogue(t, "")
|
||||
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
||||
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
||||
t.Fatalf("font-size named by %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||
|
||||
go 1.22
|
||||
@@ -1,4 +0,0 @@
|
||||
# Secrets nested below a key a mergeable file takes for its other values, which the controller cannot
|
||||
# protect alone yet (it protects top-level keys): "<module> <resource> <dotted key>". This list only shrinks:
|
||||
# a line that no longer names a finding is refused, and so is one the trunk's copy does not hold (hq issue 342).
|
||||
searxng settings server.secret_key
|
||||
@@ -1,254 +0,0 @@
|
||||
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
|
||||
// password, a token or an API key in place of the vault's (novox/hq issue 342).
|
||||
//
|
||||
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
|
||||
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
|
||||
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
|
||||
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
||||
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
||||
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
||||
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
|
||||
// in files/ is judged by the template's text.
|
||||
//
|
||||
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
||||
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
||||
// beside this check, one line per key, until the controller refuses a nested key. The list only shrinks: a
|
||||
// line there that no longer names a finding is refused, and so is a line the trunk's copy (the merge base's,
|
||||
// given with -base) does not hold. While the trunk holds no copy, the lines in seed are the trunk's.
|
||||
//
|
||||
// go run . [-base <the trunk's known>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one key of one mergeable file a layer could fill in place of the vault.
|
||||
type Finding struct {
|
||||
Module, ID, Key string
|
||||
}
|
||||
|
||||
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
|
||||
|
||||
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
content, _ := r["content"].(string)
|
||||
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
|
||||
// exactly as it would be inline.
|
||||
if name, _ := r["template"].(string); name != "" {
|
||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
|
||||
}
|
||||
content = string(text)
|
||||
}
|
||||
var doc map[string]any
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
|
||||
}
|
||||
protected := set(r["protected"])
|
||||
takes, names := r["takes-settings"]
|
||||
taken := set(takes)
|
||||
for _, key := range sortedKeys(doc) {
|
||||
for _, at := range secretsBelow(key, doc[key]) {
|
||||
switch {
|
||||
case protected[key]:
|
||||
case names && !taken[key]:
|
||||
default:
|
||||
out = append(out, Finding{m.Module, id, at})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
|
||||
func secretsBelow(at string, value any) []string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.Contains(v, "${secret:") {
|
||||
return []string{at}
|
||||
}
|
||||
case map[string]any:
|
||||
var out []string
|
||||
for _, k := range sortedKeys(v) {
|
||||
out = append(out, secretsBelow(at+"."+k, v[k])...)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
var out []string
|
||||
for i, e := range v {
|
||||
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
list, _ := v.([]any)
|
||||
for _, e := range list {
|
||||
if s, ok := e.(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
|
||||
func Known(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[strings.Join(strings.Fields(line), " ")] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// seed is the known list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
var seed = map[string]bool{
|
||||
"searxng settings server.secret_key": true,
|
||||
}
|
||||
|
||||
// Added answers the lines of known that base does not hold: a line may leave the list, never join it.
|
||||
func Added(known, base map[string]bool) []string {
|
||||
var out []string
|
||||
for line := range known {
|
||||
if !base[line] {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Judge splits the findings into those refused and the known lines that no longer name one.
|
||||
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range found {
|
||||
seen[f.String()] = true
|
||||
if !known[f.String()] {
|
||||
refused = append(refused, f)
|
||||
}
|
||||
}
|
||||
for line := range known {
|
||||
if !seen[line] {
|
||||
stale = append(stale, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
return refused, stale
|
||||
}
|
||||
|
||||
// remedy says how a finding is put right.
|
||||
func remedy(key string) string {
|
||||
top, _, nested := strings.Cut(key, ".")
|
||||
if !nested {
|
||||
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
|
||||
}
|
||||
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
|
||||
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of known (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Known(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
added := Added(known, base)
|
||||
for _, line := range added {
|
||||
fmt.Printf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — "+
|
||||
"protect the key instead (novox/hq issue 342)\n", line)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
for _, f := range refused {
|
||||
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
|
||||
f.Module, f.ID, f.Key, remedy(f.Key))
|
||||
}
|
||||
for _, line := range stale {
|
||||
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
|
||||
"remove the line\n", line)
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Refused: the key holds the vault's secret and nothing keeps a layer off it.
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||
// Refused: nested below a key the file takes.
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","merge":"json","takes-settings":["server"],`+
|
||||
`"content":"{\"server\": {\"secret_key\": \"${secret:s}\", \"port\": 1}}"}`)
|
||||
// Kept: protected.
|
||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],`+
|
||||
`"content":"{\"password\": \"${secret:admin}\", \"user\": \"admin\"}"}`)
|
||||
// Kept: the file names its keys, and not this one.
|
||||
manifest(t, root, "d", `{"id":"cfg","type":"file","merge":"json","takes-settings":["ui"],`+
|
||||
`"content":"{\"token\": \"${secret:t}\", \"ui\": {}}"}`)
|
||||
// Kept: a file that does not merge takes no setting.
|
||||
manifest(t, root, "e", `{"id":"cfg","type":"file","content":"{\"token\": \"${secret:t}\"}"}`)
|
||||
// Refused: protecting another key does not protect this one; a list element counts too.
|
||||
manifest(t, root, "f", `{"id":"cfg","type":"file","merge":"json","protected":["user"],`+
|
||||
`"content":"{\"users\": [{\"password\": \"${secret:p}\"}]}"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"a cfg password", "b cfg server.secret_key", "f cfg users[0].password"}
|
||||
if len(found) != len(want) {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
for i, f := range found {
|
||||
if f.String() != want[i] {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
|
||||
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
|
||||
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
|
||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
|
||||
for _, m := range []string{"a", "c"} {
|
||||
dir := filepath.Join(root, "modules", m, "files")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].String() != "a cfg password" {
|
||||
t.Fatalf("found %v, want [a cfg password]", found)
|
||||
}
|
||||
|
||||
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
|
||||
if _, err := Check(root); err == nil {
|
||||
t.Fatal("a template the module's directory lacks passed the check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
||||
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
||||
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
||||
if len(refused) != 1 || refused[0].String() != "a cfg password" {
|
||||
t.Fatalf("refused %v", refused)
|
||||
}
|
||||
if len(stale) != 1 || stale[0] != "gone cfg token" {
|
||||
t.Fatalf("stale %v", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHoldsNoSecretASettingCouldReplace(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("refused %v, stale %v", refused, stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALineTheTrunkDoesNotHoldIsRefused(t *testing.T) {
|
||||
trunk := map[string]bool{"b cfg server.secret_key": true}
|
||||
// A key unprotected on the branch, and its line added to the list: refused, though it matches a finding.
|
||||
branch := map[string]bool{"b cfg server.secret_key": true, "a cfg password": true}
|
||||
if added := Added(branch, trunk); len(added) != 1 || added[0] != "a cfg password" {
|
||||
t.Fatalf("added %v", added)
|
||||
}
|
||||
// A line removed is no addition.
|
||||
if added := Added(map[string]bool{}, trunk); len(added) != 0 {
|
||||
t.Fatalf("added %v", added)
|
||||
}
|
||||
// Until the trunk holds a copy, the seed stands for it, and the list as committed is within it.
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added := Added(known, seed); len(added) != 0 {
|
||||
t.Fatalf("known holds lines the seed does not: %v", added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCheckRefusesALineAddedAgainstTheTrunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "base")
|
||||
if err := os.WriteFile(base, []byte("# the trunk's\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
branch := map[string]bool{"a cfg password": true}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trunk, err := Known(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale := Judge(found, branch)
|
||||
if len(refused) != 0 || len(stale) != 0 {
|
||||
t.Fatalf("the line hides the finding from Judge alone: %v %v", refused, stale)
|
||||
}
|
||||
if added := Added(branch, trunk); len(added) != 1 {
|
||||
t.Fatalf("an added line passed: %v", added)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/sshdir
|
||||
|
||||
go 1.22
|
||||
@@ -1,161 +0,0 @@
|
||||
// Command sshdir refuses any manifest but modules/ssh-client naming a path under ~/.ssh (novox/hq ADR 0415 §6,
|
||||
// issue 1020): ssh-client owns ~/.ssh and sweeps what it does not know, so a path another module placed there
|
||||
// would be moved out at the next sweep. A module that needs something there provides it to ssh-client instead.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a
|
||||
// place, a volume; a path inside a directory of the module's own (`${dir:<id>}`) is not ~/.ssh. Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHModule is the one module allowed to name a path under ~/.ssh.
|
||||
const SSHModule = "ssh-client"
|
||||
|
||||
// underSSH is a path under ~/.ssh, or ~/.ssh itself, however the home is spelled: ${machine:account-home}/.ssh,
|
||||
// ~/.ssh, .ssh/config, or a volume's host side (…/.ssh:/root/.ssh).
|
||||
var underSSH = regexp.MustCompile(`(^|[/~])\.ssh($|[/:])`)
|
||||
|
||||
// Prose are the keys whose strings are words or a file's text, not a path: a template may say ~/.ssh in a comment.
|
||||
var Prose = map[string]bool{"description": true, "why": true, "does": true, "template": true, "content": true, "title": true}
|
||||
|
||||
// Finding is one path under ~/.ssh in a manifest other than ssh-client's.
|
||||
type Finding struct {
|
||||
Module string
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which paths 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 any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
module := filepath.Base(filepath.Dir(path))
|
||||
if obj, ok := m.(map[string]any); ok {
|
||||
if name, ok := obj["module"].(string); ok && name != "" {
|
||||
module = name
|
||||
}
|
||||
}
|
||||
if module == SSHModule {
|
||||
continue
|
||||
}
|
||||
walk(m, "", dirsOf(m), func(key, value string) {
|
||||
out = append(out, Finding{Module: module, Key: key, Value: value})
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// walk calls found for every string under v, outside Prose, that names a path under ~/.ssh.
|
||||
func walk(v any, key string, dirs map[string]string, found func(key, value string)) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if !Prose[k] {
|
||||
walk(x[k], k, dirs, found)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e, key, dirs, found)
|
||||
}
|
||||
case string:
|
||||
// A path under a directory the mesh places for the module (${dir:<id>} of a directory with a place and no path,
|
||||
// as a runs-as module's home) is that account's own .ssh, not the operator's ~/.ssh that ssh-client sweeps
|
||||
// (issue 1484, ADR 0466 §4). Any other ${dir:<id>} is resolved to the path its resource declares and judged.
|
||||
if resolved, placed := resolveDir(x, dirs); !placed && underSSH.MatchString(resolved) {
|
||||
found(key, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ofDir = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// placedDir marks a directory the mesh places for the module: it has no path of its own.
|
||||
const placedDir = "\x00placed"
|
||||
|
||||
// dirsOf are the module's directories by id: the path each declares, or placedDir for one with a place and no path.
|
||||
func dirsOf(m any) map[string]string {
|
||||
dirs := map[string]string{}
|
||||
obj, _ := m.(map[string]any)
|
||||
list, _ := obj["resources"].([]any)
|
||||
for _, r := range list {
|
||||
res, _ := r.(map[string]any)
|
||||
id, _ := res["id"].(string)
|
||||
path, _ := res["path"].(string)
|
||||
switch {
|
||||
case id == "":
|
||||
case path != "":
|
||||
dirs[id] = path
|
||||
case res["type"] == "directory":
|
||||
dirs[id] = placedDir
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// resolveDir follows a leading ${dir:<id>} through the module's directories. It answers the path with the references
|
||||
// resolved, and whether it ends under a placed directory.
|
||||
func resolveDir(x string, dirs map[string]string) (string, bool) {
|
||||
for i := 0; i < 8; i++ {
|
||||
m := ofDir.FindStringSubmatch(x)
|
||||
if m == nil {
|
||||
return x, false
|
||||
}
|
||||
to, ok := dirs[m[1]]
|
||||
if !ok {
|
||||
return x, false
|
||||
}
|
||||
if to == placedDir {
|
||||
return x, true
|
||||
}
|
||||
x = to + strings.TrimPrefix(x, m[0])
|
||||
}
|
||||
return x, false
|
||||
}
|
||||
|
||||
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 %q (%s) under ~/.ssh: only %s may, and the sweep moves anything else out (novox/hq ADR 0415 §6)\n", f.Module, f.Value, f.Key, SSHModule)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("~/.ssh: named only by ssh-client")
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, body string) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModuleNamingSSHIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "ssh-client", `{"module":"ssh-client","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
|
||||
manifest(t, root, "forge-account", `{"module":"forge-account","resources":[{"path":"${machine:account-home}/.ssh/config.d/20-forge"}]}`)
|
||||
manifest(t, root, "roster", `{"module":"roster","facts":{"hosts":{"path":".ssh/known_hosts"}}}`)
|
||||
manifest(t, root, "box", `{"module":"box","containers":[{"volumes":["~/.ssh:/root/.ssh:ro"]}]}`)
|
||||
manifest(t, root, "quiet", `{"module":"quiet","description":"reads ~/.ssh/config","resources":[{"path":"${machine:account-home}/.sshrc-not","template":"# see ~/.ssh/config"},{"path":"/etc/ssh/sshd_config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, f := range found {
|
||||
got[f.Module] = true
|
||||
}
|
||||
if len(found) != 3 || !got["forge-account"] || !got["roster"] || !got["box"] {
|
||||
t.Fatalf("refused %+v; want forge-account, roster and box", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's);
|
||||
// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused.
|
||||
func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "builder", `{"module":"builder","resources":[{"id":"state","type":"directory","place":"."},{"id":"ssh-dir","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`)
|
||||
manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "other" {
|
||||
t.Fatalf("refused %+v; want only other", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484 review: only a directory the mesh places is the module's own home. A ${dir:} that resolves to the
|
||||
// operator's home is judged by the path it resolves to.
|
||||
func TestADirResolvingToTheOperatorsHomeIsStillRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "escape", `{"module":"escape","resources":[{"id":"h","type":"directory","path":"${machine:account-home}","unknown":"module"},{"path":"${dir:h}/.ssh/config"}]}`)
|
||||
manifest(t, root, "placed", `{"module":"placed","resources":[{"id":"state","type":"directory","place":".","unknown":"program"},{"id":"s","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:s}/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "escape" {
|
||||
t.Fatalf("refused %+v; want only escape", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("the catalogue names paths under ~/.ssh outside ssh-client: %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||
|
||||
go 1.22
|
||||
@@ -1,9 +0,0 @@
|
||||
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
|
||||
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
|
||||
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
|
||||
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
|
||||
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
|
||||
# issue 658).
|
||||
#
|
||||
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
|
||||
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
|
||||
@@ -1,178 +0,0 @@
|
||||
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
|
||||
// ADR 0349 §2, design 57 package T5, issue 658).
|
||||
//
|
||||
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
|
||||
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
|
||||
// archive; the controller reads each template back into the file's content, so the
|
||||
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
|
||||
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
|
||||
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
|
||||
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
|
||||
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
|
||||
//
|
||||
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
|
||||
// collections (design 57 package L2, issue 681).
|
||||
//
|
||||
// go run . [-base <the trunk's inline>] <catalogue root>
|
||||
//
|
||||
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
listFile = "inline"
|
||||
listPathInCatalog = "checks/templates/" + listFile
|
||||
)
|
||||
|
||||
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
|
||||
func Check(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
out := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline && r["type"] == "file" {
|
||||
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
|
||||
// `templates`, files/ when the manifest says none or cannot be read here.
|
||||
func TemplatesDir(root, module string) string {
|
||||
dir := "files/"
|
||||
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
|
||||
if err != nil {
|
||||
return dir
|
||||
}
|
||||
var m struct {
|
||||
Templates string `json:"templates"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
|
||||
dir = strings.TrimSuffix(m.Templates, "/") + "/"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// Refusal is the finding for a module that keeps file text inline and is not on the list.
|
||||
func Refusal(module string, ids []string, dir string) string {
|
||||
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
|
||||
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
|
||||
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
|
||||
}
|
||||
|
||||
// Gained is the finding for a module the list gains against the trunk's copy.
|
||||
func Gained(module, dir string) string {
|
||||
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
|
||||
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
|
||||
}
|
||||
|
||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
|
||||
|
||||
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||
func Listed(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[line] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
|
||||
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
|
||||
for m := range found {
|
||||
if !listed[m] {
|
||||
refused = append(refused, m)
|
||||
}
|
||||
}
|
||||
for m := range listed {
|
||||
if _, keeps := found[m]; !keeps {
|
||||
stale = append(stale, m)
|
||||
}
|
||||
if !base[m] {
|
||||
added = append(added, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(refused)
|
||||
sort.Strings(stale)
|
||||
sort.Strings(added)
|
||||
return refused, stale, added
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
listed, err := Listed(listFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Listed(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
refused, stale, added := Judge(found, listed, base)
|
||||
for _, m := range refused {
|
||||
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
|
||||
}
|
||||
for _, m := range stale {
|
||||
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
|
||||
}
|
||||
for _, m := range added {
|
||||
fmt.Println(Gained(m, TemplatesDir(root, m)))
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func module(t *testing.T, root, name, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file resource that says its text inline is found, by module and resource; one naming its template, a
|
||||
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
|
||||
func TestEveryInlineFileTextIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
|
||||
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
|
||||
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
|
||||
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{"inline": {"a", "c"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
|
||||
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
|
||||
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
|
||||
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
|
||||
inline := map[string]bool{"old": true, "done": true, "new": true}
|
||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||
refused, stale, added := Judge(found, inline, base)
|
||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||
!reflect.DeepEqual(added, []string{"new"}) {
|
||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "inline")
|
||||
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Listed(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
|
||||
t.Fatalf("read %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
|
||||
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
|
||||
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inline, err := Listed(listFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale, _ := Judge(found, inline, inline)
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
|
||||
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
|
||||
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
|
||||
if got := TemplatesDir(root, module); got != dir {
|
||||
t.Fatalf("%s: directory %q, want %q", module, got, dir)
|
||||
}
|
||||
}
|
||||
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
|
||||
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||
t.Fatalf("refusal %q", msg)
|
||||
}
|
||||
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||
t.Fatalf("gained %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/toolchains
|
||||
|
||||
go 1.22
|
||||
@@ -1,104 +0,0 @@
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Owners names, for each toolchain package, the one module that declares it.
|
||||
var Owners = map[string]string{
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
}
|
||||
|
||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||
// the Package it owns.
|
||||
type Finding struct {
|
||||
Module, Package, Owner string
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what 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
|
||||
declared := map[string]map[string]bool{} // module -> packages
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
declared[m.Module] = map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "package" {
|
||||
continue
|
||||
}
|
||||
declared[m.Module][r.Package] = true
|
||||
if owner, ok := Owners[r.Package]; ok && owner != m.Module {
|
||||
out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner})
|
||||
}
|
||||
}
|
||||
}
|
||||
var pkgs []string
|
||||
for p := range Owners {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
for _, p := range pkgs {
|
||||
if !declared[Owners[p]][p] {
|
||||
out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true})
|
||||
}
|
||||
}
|
||||
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.Missing {
|
||||
fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n",
|
||||
f.Module, f.Package, f.Owner)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
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"}]}`,
|
||||
"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"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}}
|
||||
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":[]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue declares a toolchain package outside its owner: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The daemons' lines (novox/hq ADR 0272, its insight of 2026-10-09): the operator types one command, `nox`
|
||||
// (`mesh-cli`). The node-engine (`nox-mesh-host`) and the controller (`mesh-controller`) are daemons, and a
|
||||
// person never types their names — so no text a module can show names a line of theirs. The list is the
|
||||
// copy in retired-lines at the catalogue's root, which novox/hq's words.py prints (`--list lines`) and
|
||||
// compares; one line each, `<daemon> <command>`, and a trailing ` …` for a command that is a line only with
|
||||
// an argument after it (`mesh-controller module check` is a line; "the mesh-controller module" is prose).
|
||||
|
||||
// retiredLine is one daemon's line: the daemon, its command, and whether an argument must follow.
|
||||
type retiredLine struct {
|
||||
daemon, command string
|
||||
withArgument bool
|
||||
}
|
||||
|
||||
// readLines reads retired-lines: one line a line; blank lines and lines starting with # are not lines.
|
||||
func readLines(path string) ([]retiredLine, error) {
|
||||
entries, err := readList(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []retiredLine
|
||||
for _, e := range entries {
|
||||
withArgument := strings.HasSuffix(e, " …")
|
||||
e = strings.TrimSuffix(e, " …")
|
||||
parts := strings.Fields(e)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("retired-lines: %q is not `<daemon> <command>`", e)
|
||||
}
|
||||
out = append(out, retiredLine{daemon: parts[0], command: parts[1], withArgument: withArgument})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// linePattern matches the line whole: the daemon, white space, the command. A command that is a line only
|
||||
// with an argument is matched as a line is written — at the start of a line or a code span, a prompt allowed,
|
||||
// with an argument after it — so prose that names the daemon beside the word ("the mesh-controller module is
|
||||
// assigned") is not one.
|
||||
func linePattern(l retiredLine) *regexp.Regexp {
|
||||
daemon, command := regexp.QuoteMeta(l.daemon), regexp.QuoteMeta(l.command)
|
||||
if l.withArgument {
|
||||
tick := "`"
|
||||
return regexp.MustCompile(`(?m)(?:^|` + tick + `)[ \t]*(?:\$[ \t]*)?(` + daemon + `\s+` + command + `\s+[^\s` + tick + `])`)
|
||||
}
|
||||
return regexp.MustCompile(`(?:^|[^\w-])(` + daemon + `\s+` + command + `)(?:$|[^\w-])`)
|
||||
}
|
||||
|
||||
// checkLines finds every daemon's line in the texts.
|
||||
func checkLines(lines []retiredLine, texts []text) []string {
|
||||
var out []string
|
||||
for _, l := range lines {
|
||||
rx := linePattern(l)
|
||||
for _, t := range texts {
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
line += strings.Count(t.s[:m[2]], "\n")
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (novox/hq "+
|
||||
"ADR 0272) — in %q", t.file, line, l.daemon, l.command, excerpt(t.s, m[2], m[3])))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
+2
-23
@@ -2,9 +2,7 @@
|
||||
// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the
|
||||
// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's
|
||||
// own code that is not a test (a tool's description, the notes and errors it answers with) and every
|
||||
// description in its manifest. Comments are not read: an agent never sees them. And no daemon's line
|
||||
// (novox/hq ADR 0272, lines.go): the copy in retired-lines, the lines a person would otherwise be told to
|
||||
// type of the node-engine's or the controller's binary, where the operator's command is `nox`.
|
||||
// description in its manifest. Comments are not read: an agent never sees them.
|
||||
//
|
||||
// A word is matched whole and in any case, a space in it matching any run of white space, and its plural
|
||||
// (`s` or `es` on its last part) is matched as the word. A vendor word is
|
||||
@@ -99,26 +97,7 @@ func main() {
|
||||
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)
|
||||
fmt.Printf("words: %d retired words, none in the %d strings of %d files\n", len(words), len(texts), files)
|
||||
}
|
||||
|
||||
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// A retired word's plural is the word; anything else joined on is another word.
|
||||
func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
|
||||
@@ -27,63 +24,3 @@ func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon's line is found whole — the daemon, its command, and an argument where the command is a line only with
|
||||
// one — and prose that merely names the daemon beside a word is not one (novox/hq ADR 0272).
|
||||
func TestADaemonsLineIsFoundWhole(t *testing.T) {
|
||||
push := retiredLine{daemon: "mesh-controller", command: "push"}
|
||||
module := retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}
|
||||
handOver := retiredLine{daemon: "nox-mesh-host", command: "hand-over"}
|
||||
cases := []struct {
|
||||
line retiredLine
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{push, "run `mesh-controller push laptop` after", true},
|
||||
{push, "mesh-controller push", true},
|
||||
{push, "mesh-controller\n push", true},
|
||||
{push, "mesh-controller pushed it", false},
|
||||
{push, "the mesh-controller pushes", false},
|
||||
{push, "mesh-controller-push", false},
|
||||
{module, "`mesh-controller module check modules/*/module.json`", true},
|
||||
{module, "run\n $ mesh-controller module check x", true},
|
||||
{module, "the mesh-controller module is assigned", false},
|
||||
{module, "mesh-controller module", false},
|
||||
{module, "`mesh-controller module`", false},
|
||||
{handOver, "`nox-mesh-host hand-over /srv/x` at the machine", true},
|
||||
{handOver, "`nox node hand-over laptop /srv/x`", false},
|
||||
{retiredLine{daemon: "mesh-host", command: "hand-over"}, "nox-mesh-host hand-over", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := linePattern(c.line).MatchString(c.text); got != c.want {
|
||||
t.Errorf("%s %s on %q = %v, want %v", c.line.daemon, c.line.command, c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
found := checkLines([]retiredLine{push, module}, []text{{file: "m/a.go", line: 3, s: "say\n`mesh-controller push`"},
|
||||
{file: "m/b.go", line: 1, s: "the mesh-controller module"}})
|
||||
if len(found) != 1 || found[0] != "m/a.go:4: `mesh-controller push` is a daemon's line — the operator types `nox …` (novox/hq ADR 0272) — in \"say `mesh-controller push`\"" {
|
||||
t.Fatalf("found %q", found)
|
||||
}
|
||||
}
|
||||
|
||||
// The list's lines read as `<daemon> <command>`, a trailing ` …` meaning an argument must follow; anything else is
|
||||
// refused rather than read as some line.
|
||||
func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/retired-lines"
|
||||
if err := os.WriteFile(path, []byte("# the list\nmesh-controller push\nmesh-controller module …\n\nnox-mesh-host hand-over\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := readLines(path)
|
||||
if err != nil || len(lines) != 3 || lines[0] != (retiredLine{daemon: "mesh-controller", command: "push"}) ||
|
||||
lines[1] != (retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}) ||
|
||||
lines[2] != (retiredLine{daemon: "nox-mesh-host", command: "hand-over"}) {
|
||||
t.Fatalf("read %+v, %v", lines, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mesh-controller\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readLines(path); err == nil {
|
||||
t.Fatal("a line with no command was read")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
59
|
||||
70
|
||||
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
|
||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||
|
||||
@@ -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
|
||||
+7
-111
@@ -1,6 +1,5 @@
|
||||
#!/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.
|
||||
@@ -14,13 +13,8 @@
|
||||
# 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);
|
||||
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
|
||||
# written in TypeScript, is said as not tested, never passed silently;
|
||||
# 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,
|
||||
@@ -28,91 +22,9 @@
|
||||
# 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=""
|
||||
@@ -158,26 +70,16 @@ if [ -n "${MESH_CHECK_MODULES:-}" ]; then
|
||||
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"
|
||||
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
|
||||
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"
|
||||
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
|
||||
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
|
||||
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
|
||||
@@ -186,12 +88,6 @@ for m in $touched; do
|
||||
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 ./...)
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $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
|
||||
|
||||
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
|
||||
|
||||
| path | class | from |
|
||||
|---|---|---|
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
|
||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
||||
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
||||
|
||||
@@ -18,18 +18,10 @@ type manifest struct {
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
@@ -41,23 +33,6 @@ func readManifest(t *testing.T) manifest {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
|
||||
for _, it := range m.Items {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(it.Collection, ":")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[0] == "shell":
|
||||
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
|
||||
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
|
||||
if m.Environment.Variables == nil {
|
||||
m.Environment.Variables = map[string]string{}
|
||||
}
|
||||
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -121,10 +96,9 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
pkgs = append(pkgs, r["package"].(string))
|
||||
continue
|
||||
}
|
||||
_, inline := r["content"]
|
||||
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
||||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
||||
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
||||
}
|
||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
||||
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
||||
@@ -133,7 +107,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
||||
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
|
||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
||||
t.Fatalf("%v", qt)
|
||||
}
|
||||
@@ -142,7 +116,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
||||
}
|
||||
}
|
||||
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
||||
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
||||
t.Fatalf("%v", gtk)
|
||||
}
|
||||
@@ -270,31 +244,3 @@ func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if name, ok := r["template"].(string); ok {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
|
||||
t.Errorf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Adwaita:dark
|
||||
@@ -1 +0,0 @@
|
||||
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||
@@ -1 +0,0 @@
|
||||
qt6ct
|
||||
@@ -1 +0,0 @@
|
||||
6
|
||||
@@ -1 +0,0 @@
|
||||
Fusion
|
||||
@@ -1 +0,0 @@
|
||||
24
|
||||
@@ -1 +0,0 @@
|
||||
Adwaita
|
||||
@@ -1,10 +0,0 @@
|
||||
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
|
||||
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
|
||||
# module's default; adwaita_appearance switches it for a session.
|
||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
|
||||
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface cursor-size 24 || true
|
||||
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
|
||||
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
|
||||
@@ -1,3 +0,0 @@
|
||||
! adwaita: the cursor, for X programs that take it from the resources.
|
||||
Xcursor.theme: Adwaita
|
||||
Xcursor.size: 24
|
||||
+23
-81
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"module": "adwaita",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
@@ -11,79 +10,27 @@
|
||||
"adwaita_icons",
|
||||
"adwaita_portal_check"
|
||||
],
|
||||
"items": [
|
||||
"environment": {
|
||||
"variables": {
|
||||
"GTK_THEME": "Adwaita:dark",
|
||||
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
|
||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||
"QT_STYLE_OVERRIDE": "Fusion",
|
||||
"QT_SELECT": "6",
|
||||
"XCURSOR_THEME": "Adwaita",
|
||||
"XCURSOR_SIZE": "24"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "gtk-theme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "GTK_THEME"
|
||||
},
|
||||
"template": "files/env-gtk-theme"
|
||||
"for": "xresources",
|
||||
"slot": "normal",
|
||||
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "gtk2-rc-files",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "GTK2_RC_FILES"
|
||||
},
|
||||
"template": "files/env-gtk2-rc-files"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-qpa-platformtheme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_QPA_PLATFORMTHEME"
|
||||
},
|
||||
"template": "files/env-qt-qpa-platformtheme"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-style-override",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_STYLE_OVERRIDE"
|
||||
},
|
||||
"template": "files/env-qt-style-override"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-select",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_SELECT"
|
||||
},
|
||||
"template": "files/env-qt-select"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "xcursor-theme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "XCURSOR_THEME"
|
||||
},
|
||||
"template": "files/env-xcursor-theme"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "xcursor-size",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "XCURSOR_SIZE"
|
||||
},
|
||||
"template": "files/env-xcursor-size"
|
||||
},
|
||||
{
|
||||
"collection": "shell:xresources:normal",
|
||||
"name": "xresources-normal",
|
||||
"template": "files/xresources-normal"
|
||||
},
|
||||
{
|
||||
"collection": "shell:xinitrc:normal",
|
||||
"name": "xinitrc-normal",
|
||||
"template": "files/xinitrc-normal"
|
||||
"for": "xinitrc",
|
||||
"slot": "normal",
|
||||
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -118,7 +65,7 @@
|
||||
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/gtk-settings.ini"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||
},
|
||||
{
|
||||
"id": "gtk4",
|
||||
@@ -126,7 +73,7 @@
|
||||
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/gtk-settings.ini"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||
},
|
||||
{
|
||||
"id": "qt6ct",
|
||||
@@ -134,7 +81,7 @@
|
||||
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/qt6ct.conf"
|
||||
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
|
||||
},
|
||||
{
|
||||
"id": "portals",
|
||||
@@ -142,7 +89,7 @@
|
||||
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/portals.conf"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
|
||||
},
|
||||
{
|
||||
"id": "cursor",
|
||||
@@ -150,7 +97,7 @@
|
||||
"path": "${machine:account-home}/.icons/default/index.theme",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/cursor-index.theme"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -165,11 +112,6 @@
|
||||
"loads": [
|
||||
"adwaita-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# artifact-store-tools
|
||||
|
||||
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
|
||||
(novox/hq ADR 0251 §1–3, to-be 51). It changes nothing on its machine; its only resources are its state
|
||||
directory and the file the mesh writes there from its setting `older-store-names`.
|
||||
(novox/hq ADR 0251 §1–3, to-be 51). It declares no resources and changes nothing on its machine.
|
||||
|
||||
## Why a module beside the store
|
||||
|
||||
@@ -19,7 +18,7 @@ on that machine.
|
||||
|---|---|---|
|
||||
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
|
||||
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
|
||||
| `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
||||
| `artifact_store_references` | r | what the controller's records say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
||||
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
|
||||
|
||||
### How the store is read
|
||||
@@ -44,7 +43,6 @@ it lists. The store's collector removes every blob no manifest marks, so these n
|
||||
| state | what | removable |
|
||||
|---|---|---|
|
||||
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
|
||||
| `named-by-a-definition` | no record names it, and a definition in the catalogue pins it by digest, under the store's current name or an older one; `why` names the module (hq issue 323) | no |
|
||||
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
|
||||
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
|
||||
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
|
||||
@@ -58,20 +56,6 @@ its own right; the controller lets an index go with the platform manifests only
|
||||
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
|
||||
record names leaves its platform manifests unrecorded with it.
|
||||
|
||||
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
|
||||
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
|
||||
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
|
||||
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
|
||||
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
|
||||
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
|
||||
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
|
||||
default, set with the controller's `settings`), which the mesh writes to the module's `store-names` file: a
|
||||
catalogue names no installation. When they cannot be read, the answer's first line says so. A manifest no record
|
||||
names that a definition pins is `named-by-a-definition`; one the records name keeps the records' state. A
|
||||
definition read at its commit is kept, so the next call asks only for the list. Definitions that could
|
||||
not be read are listed under `definitions.unread`, and the answer's first line says that what they pin
|
||||
reads as unrecorded until they are.
|
||||
|
||||
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
|
||||
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
|
||||
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
|
||||
@@ -104,14 +88,6 @@ with slashes, a cut listing refused), manifests read with one absent or unreadab
|
||||
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
|
||||
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
|
||||
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
|
||||
the fall-back when the references let go of cannot be carried, a manifest a definition pins under the
|
||||
store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the
|
||||
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
|
||||
catalogue's two tools.
|
||||
the fall-back when the references let go of cannot be carried, escalation through `sudo -n`, and that the
|
||||
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs.
|
||||
Any command other than the two read-only scripts fails the test.
|
||||
|
||||
## 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).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -7,15 +7,12 @@ import (
|
||||
|
||||
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
|
||||
const (
|
||||
StateKept = "kept"
|
||||
StateHolderKept = "holder-of-kept-archive"
|
||||
StateEligible = "eligible"
|
||||
StateHolderEligible = "holder-of-eligible-archive"
|
||||
StateCollectedPresent = "let-go-yet-present"
|
||||
StateNamedDocument = "named-document"
|
||||
// StateNamedByDefinition is a manifest no record names that a definition pins by digest, under the
|
||||
// store's current name or an older one (novox/hq issue 323): a machine may run it, so it is kept.
|
||||
StateNamedByDefinition = "named-by-a-definition"
|
||||
StateKept = "kept"
|
||||
StateHolderKept = "holder-of-kept-archive"
|
||||
StateEligible = "eligible"
|
||||
StateHolderEligible = "holder-of-eligible-archive"
|
||||
StateCollectedPresent = "let-go-yet-present"
|
||||
StateNamedDocument = "named-document"
|
||||
StateUnrecorded = "unrecorded"
|
||||
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
|
||||
)
|
||||
@@ -48,8 +45,8 @@ var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z
|
||||
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
|
||||
// wins, so a platform two indexes name is as kept as the more kept of them.
|
||||
var keeping = map[string]int{
|
||||
StateKept: 0, StateNamedByDefinition: 1, StateNamedDocument: 2, StateHolderKept: 3, StateEligible: 4,
|
||||
StateHolderEligible: 5, StateCollectedPresent: 6,
|
||||
StateKept: 0, StateNamedDocument: 1, StateHolderKept: 2, StateEligible: 3, StateHolderEligible: 4,
|
||||
StateCollectedPresent: 5,
|
||||
}
|
||||
|
||||
// recordIndex finds a record by repository and digest.
|
||||
@@ -67,9 +64,8 @@ func indexRecords(r *Records) recordIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Classify gives every manifest the store holds one state. A manifest no record names that a definition
|
||||
// pins is named by a definition; defs may be nil, when the definitions were not read.
|
||||
func Classify(v *View, recs *Records, defs *Definitions) []Entry {
|
||||
// Classify gives every manifest the store holds one state.
|
||||
func Classify(v *View, recs *Records) []Entry {
|
||||
idx := indexRecords(recs)
|
||||
var out []Entry
|
||||
for _, name := range v.L.RepoNames() {
|
||||
@@ -103,12 +99,6 @@ func Classify(v *View, recs *Records, defs *Definitions) []Entry {
|
||||
e.State = StateNamedDocument
|
||||
}
|
||||
}
|
||||
if e.State == StateUnrecorded && defs != nil {
|
||||
for _, p := range defs.Pins[Key(name, d)] {
|
||||
e.State = StateNamedByDefinition
|
||||
e.Why = append(e.Why, p.why())
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the definitions pin in the store (novox/hq issue 323). A definition may pin an image of the store by
|
||||
// digest that the mesh did not make: one pushed before the mesh built it, pinned under the store's current
|
||||
// name or under a name the store had before. No record of the controller names such a manifest, and yet a
|
||||
// machine runs it, so it is said to be named by a definition and never unrecorded.
|
||||
|
||||
// NamesFileEnv names the file the mesh writes from the setting older-store-names: a line `older=` with the
|
||||
// names the store had before, as a definition may still write them — host names, or host:port, separated by
|
||||
// spaces — or `none`. A catalogue names no installation, so the names are the mesh's setting, not the
|
||||
// manifest's.
|
||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||
|
||||
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||
const (
|
||||
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||
CatalogueModule = "mesh-catalog.catalog_module"
|
||||
)
|
||||
|
||||
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
|
||||
// was read is kept, so a call that runs out reads only the rest the next time.
|
||||
const DefinitionsTimeout = 20 * time.Second
|
||||
|
||||
// definitionReaders is how many definitions are asked at once.
|
||||
const definitionReaders = 8
|
||||
|
||||
// StoreNames are the names a definition may pin an image of the store under: the scheme the mesh writes
|
||||
// (artifact-store://), the store's address now, and the names it had before.
|
||||
type StoreNames struct {
|
||||
Current []string
|
||||
Older []string
|
||||
}
|
||||
|
||||
// NamesOf is the store's names: its address as the controller answers it, and the older ones given.
|
||||
func NamesOf(address, older string) StoreNames {
|
||||
n := StoreNames{Current: []string{"artifact-store://"}}
|
||||
if a := strings.Trim(strings.TrimSpace(address), "/"); a != "" {
|
||||
n.Current = append(n.Current, a+"/")
|
||||
}
|
||||
for _, o := range strings.FieldsFunc(older, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) {
|
||||
if o = strings.Trim(o, "/"); o != "" && o != "none" {
|
||||
n.Older = append(n.Older, o+"/")
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// OlderNames reads the older names from the file the mesh writes, and says why when it cannot.
|
||||
func OlderNames() (string, string) {
|
||||
path := os.Getenv(NamesFileEnv)
|
||||
if path == "" {
|
||||
return "", NamesFileEnv + " is not set, so no older name of the store was read"
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "the store's older names could not be read: " + err.Error()
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "older="); ok {
|
||||
return v, ""
|
||||
}
|
||||
}
|
||||
return "", path + " holds no line older=, so no older name of the store was read"
|
||||
}
|
||||
|
||||
// Pin is one definition pinning a manifest of the store by digest.
|
||||
type Pin struct {
|
||||
Module string `json:"module"`
|
||||
// Name is the store's name it is pinned under, as written: "artifact-store://", an address, or an older name.
|
||||
Name string `json:"name"`
|
||||
Older bool `json:"older,omitempty"`
|
||||
}
|
||||
|
||||
// pinned is one pin found in a manifest: where in the store, and under which name.
|
||||
type pinned struct {
|
||||
Repository, Digest, Name string
|
||||
Older bool
|
||||
}
|
||||
|
||||
var pinShapes sync.Map // name -> *regexp.Regexp
|
||||
|
||||
// pinShape is `<name><repository>@sha256:<hex>`, the name standing on its own: not the tail of a longer host.
|
||||
func pinShape(name string) *regexp.Regexp {
|
||||
if re, ok := pinShapes.Load(name); ok {
|
||||
return re.(*regexp.Regexp)
|
||||
}
|
||||
re := regexp.MustCompile(`(?:^|[^A-Za-z0-9.-])` + regexp.QuoteMeta(name) +
|
||||
`([a-z0-9][a-z0-9._/-]*)@(sha256:[0-9a-f]{64})`)
|
||||
pinShapes.Store(name, re)
|
||||
return re
|
||||
}
|
||||
|
||||
// PinsIn finds every image of the store a manifest pins by digest, in any string it holds: a resource's
|
||||
// image, a build's base, a file's content.
|
||||
func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||
var doc any
|
||||
if json.Unmarshal(manifest, &doc) != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []pinned
|
||||
var walk func(any)
|
||||
walk = func(x any) {
|
||||
switch v := x.(type) {
|
||||
case map[string]any:
|
||||
for _, e := range v {
|
||||
walk(e)
|
||||
}
|
||||
case []any:
|
||||
for _, e := range v {
|
||||
walk(e)
|
||||
}
|
||||
case string:
|
||||
for _, group := range []struct {
|
||||
names []string
|
||||
older bool
|
||||
}{{names.Current, false}, {names.Older, true}} {
|
||||
for _, name := range group.names {
|
||||
for _, m := range pinShape(name).FindAllStringSubmatch(v, -1) {
|
||||
p := pinned{Repository: strings.Trim(m[1], "/"), Digest: m[2], Name: name, Older: group.older}
|
||||
if k := p.Repository + "@" + p.Digest + " " + p.Name; !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||
type Definitions struct {
|
||||
// Pins are by Key(repository, digest).
|
||||
Pins map[string][]Pin
|
||||
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||
Read int
|
||||
Unread map[string]string
|
||||
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||
NotAsked string
|
||||
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||
NamesNotRead string
|
||||
Names StoreNames
|
||||
}
|
||||
|
||||
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||
// module and commit, which never change together.
|
||||
type Catalogue struct {
|
||||
Ask Asking
|
||||
|
||||
mu sync.Mutex
|
||||
kept map[string][]pinned // "<module>@<commit>|<names>" -> its pins
|
||||
}
|
||||
|
||||
type catalogueEntry struct {
|
||||
Module string `json:"module"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||
if c == nil || c.Ask == nil {
|
||||
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||
return defs
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||
if err != nil {
|
||||
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||
return defs
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||
return defs
|
||||
}
|
||||
var list struct {
|
||||
Modules []catalogueEntry `json:"modules"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
return defs
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.kept == nil {
|
||||
c.kept = map[string][]pinned{}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
nameKey := strings.Join(names.Current, ",") + ";" + strings.Join(names.Older, ",")
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, DefinitionsTimeout)
|
||||
defer cancel()
|
||||
type result struct {
|
||||
module string
|
||||
pins []pinned
|
||||
why string
|
||||
}
|
||||
results := make(chan result, len(list.Modules))
|
||||
work := make(chan catalogueEntry)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < definitionReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for m := range work {
|
||||
pins, why := c.definition(m, names, nameKey)
|
||||
results <- result{m.Module, pins, why}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
defer close(work)
|
||||
for _, m := range list.Modules {
|
||||
select {
|
||||
case work <- m:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() { wg.Wait(); close(results) }()
|
||||
|
||||
got := map[string]bool{}
|
||||
collect:
|
||||
for {
|
||||
select {
|
||||
case r, open := <-results:
|
||||
if !open {
|
||||
break collect
|
||||
}
|
||||
got[r.module] = true
|
||||
if r.why != "" {
|
||||
defs.Unread[r.module] = r.why
|
||||
continue
|
||||
}
|
||||
defs.Read++
|
||||
for _, p := range r.pins {
|
||||
k := Key(p.Repository, p.Digest)
|
||||
defs.Pins[k] = append(defs.Pins[k], Pin{Module: r.module, Name: p.Name, Older: p.Older})
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break collect
|
||||
}
|
||||
}
|
||||
for _, m := range list.Modules {
|
||||
if !got[m.Module] {
|
||||
defs.Unread[m.Module] = fmt.Sprintf("not read within %d s", int(DefinitionsTimeout/time.Second))
|
||||
}
|
||||
}
|
||||
for k := range defs.Pins {
|
||||
sort.Slice(defs.Pins[k], func(i, j int) bool { return defs.Pins[k][i].Module < defs.Pins[k][j].Module })
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// definition reads one module's definition, or answers it from what was read before.
|
||||
func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey string) ([]pinned, string) {
|
||||
key := m.Module + "@" + m.Commit + "|" + nameKey
|
||||
if m.Commit != "" {
|
||||
c.mu.Lock()
|
||||
pins, had := c.kept[key]
|
||||
c.mu.Unlock()
|
||||
if had {
|
||||
return pins, ""
|
||||
}
|
||||
}
|
||||
args := map[string]any{"module": m.Module}
|
||||
if m.Commit != "" {
|
||||
args["commit"] = m.Commit
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModule, args)
|
||||
if err != nil {
|
||||
return nil, "could not be asked: " + err.Error()
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, "refused: " + lastLine(output)
|
||||
}
|
||||
var one struct {
|
||||
Module *struct {
|
||||
Manifest json.RawMessage `json:"manifest"`
|
||||
} `json:"module"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &one); err != nil || one.Error != "" || one.Module == nil || len(one.Module.Manifest) == 0 {
|
||||
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
|
||||
}
|
||||
pins := PinsIn(one.Module.Manifest, names)
|
||||
if m.Commit != "" {
|
||||
c.mu.Lock()
|
||||
c.kept[key] = pins
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return pins, ""
|
||||
}
|
||||
|
||||
// why says which definitions name a manifest, and under which of the store's names.
|
||||
func (p Pin) why() string {
|
||||
if p.Older {
|
||||
return fmt.Sprintf("a definition names it: %s, under %s, an older name of the store", p.Module, shownName(p.Name))
|
||||
}
|
||||
return "a definition names it: " + p.Module
|
||||
}
|
||||
|
||||
func firstOf(s ...string) string {
|
||||
for _, x := range s {
|
||||
if strings.TrimSpace(x) != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// shownName is a name of the store as a person reads it: a host without the slash a pin puts after it, the
|
||||
// scheme as written.
|
||||
func shownName(name string) string {
|
||||
if strings.HasSuffix(name, "://") {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSuffix(name, "/")
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A definition may pin an image of the store the mesh did not make, under a name the store had before
|
||||
// (novox/hq issue 323): three apps the anchor runs are pinned so. No record names them; a definition does.
|
||||
|
||||
const oldName = "registry-api.example.org"
|
||||
|
||||
// container is a definition whose one container runs image.
|
||||
func container(image string) string {
|
||||
return fmt.Sprintf(`{"module":"x","resources":[{"id":"server","type":"container","image":%q}]}`, image)
|
||||
}
|
||||
|
||||
func TestAnImageADefinitionPinsUnderAnOlderNameOfTheStoreIsNamedByADefinition(t *testing.T) {
|
||||
v := view(t, world())
|
||||
names := NamesOf("store:5000", oldName)
|
||||
defs := &Definitions{Pins: map[string][]Pin{}}
|
||||
for module, manifest := range map[string]string{
|
||||
// m-stranger: no record names it; the invoicing app pins it under the store's older name.
|
||||
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||
// m-old: the records say eligible; a definition pinning it does not change what the records say.
|
||||
"website": container("store:5000/app/server@" + d("m-old")),
|
||||
} {
|
||||
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||
defs.Pins[Key(p.Repository, p.Digest)] = append(defs.Pins[Key(p.Repository, p.Digest)], Pin{Module: module, Name: p.Name, Older: p.Older})
|
||||
}
|
||||
}
|
||||
got := stateByName(Classify(v, withFill(records()), defs))
|
||||
stranger := got["app/server "+d("m-stranger")]
|
||||
if stranger.State != StateNamedByDefinition {
|
||||
t.Fatalf("a manifest a definition pins under an older name reads %q, want %q", stranger.State, StateNamedByDefinition)
|
||||
}
|
||||
if want := []string{"a definition names it: invoicing, under " + oldName + ", an older name of the store"}; !reflect.DeepEqual(stranger.Why, want) {
|
||||
t.Errorf("why %q, want %q", stranger.Why, want)
|
||||
}
|
||||
if s := got["app/server "+d("m-old")].State; s != StateEligible {
|
||||
t.Errorf("a recorded manifest a definition pins reads %q: the records decide what the mesh made", s)
|
||||
}
|
||||
// Without the definitions it is what it was: unrecorded.
|
||||
if s := stateByName(Classify(v, withFill(records()), nil))["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("without definitions %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinsAreFoundInEveryStringAndOnlyUnderTheStoresNames(t *testing.T) {
|
||||
names := NamesOf("store:5000", oldName+", older.example/")
|
||||
manifest := `{"resources":[
|
||||
{"type":"container","image":"` + oldName + `/team/www@` + d("www") + `"},
|
||||
{"type":"file","content":"IMAGE=older.example/team/tool@` + d("tool") + `\n"},
|
||||
{"type":"container","image":"artifact-store://mod/art@` + d("built") + `"},
|
||||
{"type":"container","image":"store:5000/mod/art@` + d("addressed") + `"},
|
||||
{"type":"container","image":"not` + oldName + `/team/www@` + d("lookalike") + `"},
|
||||
{"type":"container","image":"docker.io/library/nginx@` + d("upstream") + `"},
|
||||
{"type":"container","image":"` + oldName + `/team/www:latest"}],
|
||||
"build":{"on":{"base":"` + oldName + `/team/www@` + d("www") + `"}}}`
|
||||
var got []string
|
||||
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||
got = append(got, fmt.Sprintf("%s@%s %s %v", p.Repository, p.Digest, p.Name, p.Older))
|
||||
}
|
||||
want := map[string]bool{
|
||||
"team/www@" + d("www") + " " + oldName + "/ true": true,
|
||||
"team/tool@" + d("tool") + " older.example/ true": true,
|
||||
"mod/art@" + d("built") + " artifact-store:// false": true,
|
||||
"mod/art@" + d("addressed") + " store:5000/ false": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("found %d pins, want %d (one per image and name; not a longer host's tail, not another registry, not a tag): %v", len(got), len(want), got)
|
||||
}
|
||||
for _, g := range got {
|
||||
if !want[g] {
|
||||
t.Errorf("found %s", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlatformTakesTheStateOfAnIndexADefinitionNames(t *testing.T) {
|
||||
v := mirrorWorld()
|
||||
defs := &Definitions{Pins: map[string][]Pin{
|
||||
Key("route-proxy/on-go_base", d("i-pre")): {{Module: "route-proxy", Name: oldName + "/", Older: true}},
|
||||
}}
|
||||
got := stateByName(Classify(v, withFill(mirrorRecords()), defs))
|
||||
for _, name := range []string{"i-pre", "p-pre"} {
|
||||
if s := got["route-proxy/on-go_base "+d(name)].State; s != StateNamedByDefinition {
|
||||
t.Errorf("%s reads %q, want %q", name, s, StateNamedByDefinition)
|
||||
}
|
||||
}
|
||||
if e := got["route-proxy/on-go_base "+d("p-pre")]; e.Index != d("i-pre") {
|
||||
t.Errorf("p-pre takes its state from %q", e.Index)
|
||||
}
|
||||
if s := got["route-proxy/on-go_base "+d("p-lost")].State; s != StateUnrecorded {
|
||||
t.Errorf("a platform nothing names reads %q", s)
|
||||
}
|
||||
for _, um := range UnrecordedMirrors(v, Classify(v, withFill(mirrorRecords()), defs)) {
|
||||
if strings.Contains(um.Reference, hexOf(d("i-pre"))) {
|
||||
t.Errorf("a copy a definition names is offered to the mirrors verb: %v", um)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// olderNames writes the file the mesh writes from the setting older-store-names.
|
||||
func olderNames(t *testing.T, names string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "store-names")
|
||||
if err := os.WriteFile(path, []byte("# written by the mesh\nolder="+names+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(NamesFileEnv, path)
|
||||
}
|
||||
|
||||
func TestTheOlderNamesAreReadFromTheFileTheMeshWrites(t *testing.T) {
|
||||
olderNames(t, oldName+" older.example")
|
||||
got, why := OlderNames()
|
||||
if why != "" || !reflect.DeepEqual(NamesOf("", got).Older, []string{oldName + "/", "older.example/"}) {
|
||||
t.Errorf("read %q (%s)", got, why)
|
||||
}
|
||||
olderNames(t, "none")
|
||||
if got, why := OlderNames(); why != "" || len(NamesOf("", got).Older) != 0 {
|
||||
t.Errorf("none read as %q (%s)", got, why)
|
||||
}
|
||||
t.Setenv(NamesFileEnv, filepath.Join(t.TempDir(), "absent"))
|
||||
if _, why := OlderNames(); why == "" {
|
||||
t.Error("an absent file was not said")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlderNamesNotReadAreSaidFirst(t *testing.T) {
|
||||
t.Setenv(NamesFileEnv, "")
|
||||
a := &asked{definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger"))}}
|
||||
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, NamesFileEnv) {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("m-stranger reads %q without the older names", s)
|
||||
}
|
||||
}
|
||||
|
||||
// references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle.
|
||||
func references(t *testing.T, a *asked) func(map[string]any) (any, error) {
|
||||
f := world()
|
||||
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||
if tl.Name == "artifact_store_references" {
|
||||
return tl.Run
|
||||
}
|
||||
}
|
||||
t.Fatal("no artifact_store_references")
|
||||
return nil
|
||||
}
|
||||
|
||||
func manifestsOf(t *testing.T, out any) map[string]Entry {
|
||||
t.Helper()
|
||||
entries, ok := out.(map[string]any)["manifests"].([]Entry)
|
||||
if !ok {
|
||||
t.Fatalf("no manifests in %v", out)
|
||||
}
|
||||
return stateByName(entries)
|
||||
}
|
||||
|
||||
func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
|
||||
olderNames(t, oldName)
|
||||
a := &asked{definitions: map[string]string{
|
||||
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||
"grafana": container("grafana/grafana@" + d("upstream")),
|
||||
}}
|
||||
run := references(t, a)
|
||||
out, err := run(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := manifestsOf(t, out)["app/server "+d("m-stranger")]
|
||||
if e.State != StateNamedByDefinition || len(e.Why) != 1 || !strings.Contains(e.Why[0], "invoicing") || !strings.Contains(e.Why[0], oldName) {
|
||||
t.Errorf("m-stranger: %+v", e)
|
||||
}
|
||||
said := out.(map[string]any)["definitions"].(map[string]any)
|
||||
if said["read"] != 2 || !reflect.DeepEqual(said["older_store_names"], []string{oldName}) ||
|
||||
!reflect.DeepEqual(said["store_names"], []string{"artifact-store://", "store:5000"}) {
|
||||
t.Errorf("definitions said %v", said)
|
||||
}
|
||||
if w := out.(map[string]any)["said"].([]string)[0]; strings.Contains(w, "could not be read") {
|
||||
t.Errorf("a warning with every definition read: %s", w)
|
||||
}
|
||||
// A definition read at its commit is kept: the next call asks only for the list.
|
||||
before := 0
|
||||
for _, c := range a.calls {
|
||||
if c == CatalogueModule {
|
||||
before++
|
||||
}
|
||||
}
|
||||
if _, err := run(map[string]any{"repository": "app/server"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := 0
|
||||
for _, c := range a.calls {
|
||||
if c == CatalogueModule {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if before != 2 || after != 2 {
|
||||
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADefinitionNotReadIsSaidFirstAndLeavesWhatItPinsUnrecorded(t *testing.T) {
|
||||
olderNames(t, oldName)
|
||||
a := &asked{
|
||||
definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger")), "grafana": container("x")},
|
||||
failing: map[string]bool{"invoicing": true},
|
||||
}
|
||||
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("m-stranger reads %q with its definition unread", s)
|
||||
}
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, "1 definitions could not be read") {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if u := m["definitions"].(map[string]any)["unread"].(map[string]string); u["invoicing"] == "" || len(u) != 1 {
|
||||
t.Errorf("unread %v", u)
|
||||
}
|
||||
|
||||
// The catalogue not answering at all: the records are still answered, and the warning stands first.
|
||||
a = &asked{noCatalogue: true}
|
||||
out, err = references(t, a)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = out.(map[string]any)
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, "the definitions could not be read") || !strings.Contains(w, "no responders") {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if m["definitions"].(map[string]any)["not_asked"] == nil {
|
||||
t.Errorf("definitions %v", m["definitions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCatalogueIsSaidAndNothingIsAsked(t *testing.T) {
|
||||
defs := (&Catalogue{}).Read(context.Background(), NamesOf("", ""))
|
||||
if defs.NotAsked == "" || defs.Read != 0 {
|
||||
t.Errorf("%+v", defs)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
UID: os.Getuid(),
|
||||
}
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask}, &Catalogue{Ask: stdio.Ask})); err != nil {
|
||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
||||
for i := range recs.References {
|
||||
fill(&recs.References[i])
|
||||
}
|
||||
got := stateByName(Classify(v, recs, nil))
|
||||
got := stateByName(Classify(v, recs))
|
||||
up := "upstream/docker.io/library/golang "
|
||||
for name, want := range map[string]string{
|
||||
up + d("p-amd"): StateEligible, // only the eligible index names it
|
||||
@@ -92,7 +92,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
||||
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
|
||||
t.Errorf("offered as unrecorded copies: %v", mirrors)
|
||||
}
|
||||
um := UnrecordedMirrors(v, Classify(v, recs, nil))
|
||||
um := UnrecordedMirrors(v, Classify(v, recs))
|
||||
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
|
||||
t.Errorf("unrecorded copies: %+v", um)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestLettingAnIndexGoTakesItsOwnPlatformsAndSaysWhatStaysAndNamesOne(t *test
|
||||
for i := range recs.References {
|
||||
fill(&recs.References[i])
|
||||
}
|
||||
entries := Classify(v, recs, nil)
|
||||
entries := Classify(v, recs)
|
||||
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
|
||||
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
|
||||
up := "upstream/docker.io/library/golang"
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
|
||||
func TestEveryStateIsGiven(t *testing.T) {
|
||||
v := view(t, world())
|
||||
got := map[string]string{}
|
||||
for _, e := range Classify(v, withFill(records()), nil) {
|
||||
for _, e := range Classify(v, withFill(records())) {
|
||||
got[e.Repository+" "+e.Digest] = e.State
|
||||
}
|
||||
want := map[string]string{
|
||||
@@ -239,7 +239,7 @@ func TestALetGoReferenceStillPresentIsSaidSo(t *testing.T) {
|
||||
v := view(t, world())
|
||||
r := records()
|
||||
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
|
||||
for _, e := range Classify(v, withFill(r), nil) {
|
||||
for _, e := range Classify(v, withFill(r)) {
|
||||
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
|
||||
t.Errorf("state %s", e.State)
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
|
||||
|
||||
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
|
||||
v := view(t, world())
|
||||
entries := Classify(v, withFill(records()), nil)
|
||||
entries := Classify(v, withFill(records()))
|
||||
sums := Summarise(v, entries, entries)
|
||||
el := sums[StateEligible]
|
||||
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
|
||||
@@ -284,11 +284,6 @@ type asked struct {
|
||||
calls []string
|
||||
args []map[string]any
|
||||
fail map[string]bool
|
||||
// definitions are the catalogue's modules and their manifests; failing, the modules whose
|
||||
// definition cannot be read; noCatalogue, the catalogue not answering at all.
|
||||
definitions map[string]string
|
||||
failing map[string]bool
|
||||
noCatalogue bool
|
||||
}
|
||||
|
||||
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
@@ -304,31 +299,6 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
switch key {
|
||||
case "seat:mesh-controller.artifacts":
|
||||
answer = records()
|
||||
case CatalogueModules:
|
||||
if a.noCatalogue {
|
||||
return nil, fmt.Errorf("no responders")
|
||||
}
|
||||
ms := []map[string]any{}
|
||||
for name := range a.definitions {
|
||||
ms = append(ms, map[string]any{"module": name, "commit": "c-" + name, "path": "modules/" + name})
|
||||
}
|
||||
answer = map[string]any{"modules": ms}
|
||||
case CatalogueModule:
|
||||
name, _ := args["module"].(string)
|
||||
if a.failing[name] {
|
||||
return nil, fmt.Errorf("no answer")
|
||||
}
|
||||
if args["commit"] != "c-"+name {
|
||||
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
|
||||
}
|
||||
manifest, has := a.definitions[name]
|
||||
if !has {
|
||||
answer = map[string]any{"error": "the catalogue holds no " + name}
|
||||
break
|
||||
}
|
||||
// The catalogue's tool answers through the runtime as the protocol's content list.
|
||||
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
|
||||
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
|
||||
case "seat:mesh-controller.collect":
|
||||
if args["confirm"] == "true" {
|
||||
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
|
||||
@@ -345,7 +315,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
|
||||
f := world()
|
||||
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}) {
|
||||
if tl.Name == name {
|
||||
return tl.Run
|
||||
}
|
||||
@@ -416,7 +386,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var served []string
|
||||
for _, tl := range Tools(&Store{}, Controller{}, &Catalogue{}) {
|
||||
for _, tl := range Tools(&Store{}, Controller{}) {
|
||||
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
|
||||
t.Errorf("tool %q", tl.Name)
|
||||
}
|
||||
@@ -428,7 +398,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
if !reflect.DeepEqual(served, listed) {
|
||||
t.Fatalf("served %v, manifest %v", served, listed)
|
||||
}
|
||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect", CatalogueModules, CatalogueModule}
|
||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect"}
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Errorf("invokes %v", m.Invokes)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
|
||||
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
|
||||
func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
func Tools(s *Store, c Controller) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
|
||||
return []stdio.Tool{
|
||||
@@ -53,9 +53,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
{
|
||||
Name: "artifact_store_references",
|
||||
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
|
||||
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||
"an older one: the module is said), the holder of a kept archive, " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), the holder of a kept archive, " +
|
||||
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||
"takes the state of the recorded index naming it. Counted and sized by " +
|
||||
@@ -69,19 +67,11 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The definitions are read while the store is: each bounded below the runtime's call limit.
|
||||
read := make(chan *Definitions, 1)
|
||||
go func() {
|
||||
older, why := OlderNames()
|
||||
defs := cat.Read(ctx, NamesOf(recs.Store, older))
|
||||
defs.NamesNotRead = why
|
||||
read <- defs
|
||||
}()
|
||||
v, err := s.View(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return References(v, recs, <-read, repo)
|
||||
return References(v, recs, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -126,7 +116,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
if err != nil {
|
||||
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
|
||||
}
|
||||
return Collected(v, answer, dry, Classify(v, recs, nil)), nil
|
||||
return Collected(v, answer, dry, Classify(v, recs)), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -235,11 +225,11 @@ func Usage(v *View, top int) map[string]any {
|
||||
}
|
||||
|
||||
// References answers artifact_store_references.
|
||||
func References(v *View, recs *Records, defs *Definitions, only string) (map[string]any, error) {
|
||||
func References(v *View, recs *Records, only string) (map[string]any, error) {
|
||||
if only != "" && v.L.Repos[only] == nil {
|
||||
return nil, fmt.Errorf("the store holds no repository %q", only)
|
||||
}
|
||||
entries := Classify(v, recs, defs)
|
||||
entries := Classify(v, recs)
|
||||
byRepo := map[string][]Entry{}
|
||||
for _, e := range entries {
|
||||
byRepo[e.Repository] = append(byRepo[e.Repository], e)
|
||||
@@ -253,7 +243,7 @@ func References(v *View, recs *Records, defs *Definitions, only string) (map[str
|
||||
}
|
||||
whole := Summarise(v, entries, entries)
|
||||
said := []string{}
|
||||
for _, state := range []string{StateKept, StateNamedByDefinition, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
||||
for _, state := range []string{StateKept, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
||||
if s := whole[state]; s != nil {
|
||||
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
|
||||
}
|
||||
@@ -286,57 +276,9 @@ func References(v *View, recs *Records, defs *Definitions, only string) (map[str
|
||||
if u := unreadNote(v); u != nil {
|
||||
out["unread"] = u
|
||||
}
|
||||
out["definitions"] = definitionsSaid(defs)
|
||||
if warn := definitionsWarning(defs); warn != "" {
|
||||
// Said first: what it leaves out is a manifest a machine may run, read as unrecorded.
|
||||
out["said"] = append([]string{warn}, said...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// definitionsSaid is how the definitions were read: how many, under which names, and what was not read.
|
||||
func definitionsSaid(defs *Definitions) map[string]any {
|
||||
if defs == nil {
|
||||
return map[string]any{"read": 0, "not_asked": "the definitions were not read"}
|
||||
}
|
||||
var current, older []string
|
||||
for _, n := range defs.Names.Current {
|
||||
current = append(current, shownName(n))
|
||||
}
|
||||
for _, n := range defs.Names.Older {
|
||||
older = append(older, shownName(n))
|
||||
}
|
||||
out := map[string]any{"read": defs.Read, "store_names": nonNil(current), "older_store_names": nonNil(older)}
|
||||
if defs.NotAsked != "" {
|
||||
out["not_asked"] = defs.NotAsked
|
||||
}
|
||||
if defs.NamesNotRead != "" {
|
||||
out["older_store_names_not_read"] = defs.NamesNotRead
|
||||
}
|
||||
if len(defs.Unread) > 0 {
|
||||
out["unread"] = defs.Unread
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// definitionsWarning says, when any definition was not read, that a manifest it pins reads as unrecorded.
|
||||
func definitionsWarning(defs *Definitions) string {
|
||||
switch {
|
||||
case defs == nil:
|
||||
return ""
|
||||
case defs.NotAsked != "":
|
||||
return "the definitions could not be read (" + defs.NotAsked + "), so a manifest a definition pins " +
|
||||
"and no record names reads as unrecorded here: ask again before reading unrecorded as unused"
|
||||
case defs.NamesNotRead != "":
|
||||
return defs.NamesNotRead + ", so a manifest a definition pins under an older name of the store and no " +
|
||||
"record names reads as unrecorded here"
|
||||
case len(defs.Unread) > 0:
|
||||
return fmt.Sprintf("%d definitions could not be read (listed in definitions.unread), so a manifest one "+
|
||||
"of them pins and no record names reads as unrecorded here: ask again — what was read is kept", len(defs.Unread))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
|
||||
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
|
||||
refs := a.LetGo
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
|
||||
# artifact_store_references reads every definition under these too (novox/hq issue 323).
|
||||
# Replaced on every push; change the setting, never this file.
|
||||
older=${config:older-store-names}
|
||||
@@ -3,40 +3,13 @@
|
||||
"version": "1",
|
||||
"invokes": [
|
||||
"seat:mesh-controller.artifacts",
|
||||
"seat:mesh-controller.collect",
|
||||
"mesh-catalog.catalog_modules",
|
||||
"mesh-catalog.catalog_module"
|
||||
"seat:mesh-controller.collect"
|
||||
],
|
||||
"tools": [
|
||||
"artifact_store_repositories",
|
||||
"artifact_store_usage",
|
||||
"artifact_store_references",
|
||||
"artifact_store_collect"
|
||||
],
|
||||
"config": {
|
||||
"older-store-names": {
|
||||
"type": "string",
|
||||
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
||||
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||
"default": "none"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "store-names",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/store-names",
|
||||
"mode": "0600",
|
||||
"trusted": false,
|
||||
"template": "files/store-names"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
@@ -51,14 +24,8 @@
|
||||
"artifact-store-tools"
|
||||
],
|
||||
"env": {
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -385,15 +385,3 @@ The module no longer writes a file into i3's `config.d`. Its window-manager line
|
||||
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
|
||||
them in its own configuration under a `# <module>` line, so this module depends on a window manager
|
||||
being assigned beside it.
|
||||
|
||||
## 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).
|
||||
|
||||
- `/usr/local/lib/asus-zephyrus-g14`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-suspend.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-suspend-then-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/nvidia-powerd.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/UPower/UPower.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/X11/xorg.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -34,9 +34,11 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
||||
home := "/home/operator"
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||
own := m.i3Lines()
|
||||
if own == "" || len(m.Contributions) != 0 {
|
||||
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
||||
var own string
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-display-session" {
|
||||
own += c.Content
|
||||
}
|
||||
}
|
||||
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||
|
||||
@@ -15,41 +15,19 @@ type manifest struct {
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
||||
// triggers, its text the template file beside the manifest.
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
||||
Contributions []any `json:"contributions"`
|
||||
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
}
|
||||
|
||||
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||
func (m manifest) i3Lines() string {
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-display-session:config" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
||||
// item of the collection contribution:node-hotkeys:trigger.
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
||||
func (m manifest) triggers() string {
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-hotkeys:trigger" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
||||
return c.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
||||
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
||||
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Templates string `json:"templates"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Templates != "templates" {
|
||||
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
||||
}
|
||||
files := 0
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
files++
|
||||
name, _ := r["template"].(string)
|
||||
if !strings.HasPrefix(name, "templates/") {
|
||||
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
||||
continue
|
||||
}
|
||||
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
||||
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
t.Fatal("no file resource")
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "templates" {
|
||||
packs++
|
||||
if a["name"] != "templates" {
|
||||
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
||||
}
|
||||
// The templates archive is read by the controller, never unpacked onto the machine.
|
||||
for _, r := range m.Resources {
|
||||
if r["artifact"] == "templates" {
|
||||
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
||||
}
|
||||
}
|
||||
// And files/ holds what the machine runs, no template.
|
||||
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
||||
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"templates": "templates",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
@@ -54,85 +52,80 @@
|
||||
"id": "scripts",
|
||||
"type": "archive",
|
||||
"path": "/usr/local/lib/asus-zephyrus-g14",
|
||||
"artifact": "scripts",
|
||||
"unknown": "program"
|
||||
"artifact": "scripts"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/modprobe.d/g14-nvidia-power.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
||||
},
|
||||
{
|
||||
"id": "video-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/modprobe.d/video-brightness-switch.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-then-hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend-then-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "powerd-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "powerd-opt-in",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
||||
},
|
||||
{
|
||||
"id": "backlight-rule",
|
||||
"type": "file",
|
||||
"path": "/etc/udev/rules.d/90-backlight.rules",
|
||||
"mode": "0644",
|
||||
"template": "templates/udev/rules.d/90-backlight.rules"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
||||
},
|
||||
{
|
||||
"id": "udev",
|
||||
@@ -151,15 +144,14 @@
|
||||
"id": "upower-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/UPower/UPower.conf.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "low-battery",
|
||||
"type": "file",
|
||||
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
||||
},
|
||||
{
|
||||
"id": "upower",
|
||||
@@ -175,30 +167,14 @@
|
||||
"id": "xorg-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/X11/xorg.conf.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "touchpad",
|
||||
"type": "file",
|
||||
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"collection": "shell:after-wake:normal",
|
||||
"template": "templates/after-wake"
|
||||
},
|
||||
{
|
||||
"collection": "contribution:node-hotkeys:trigger",
|
||||
"template": "templates/hotkeys-triggers"
|
||||
},
|
||||
{
|
||||
"collection": "contribution:node-display-session:config",
|
||||
"name": "display-session",
|
||||
"template": "templates/display-session"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -218,12 +194,31 @@
|
||||
"name": "scripts",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
},
|
||||
{
|
||||
"name": "templates",
|
||||
"kind": "archive",
|
||||
"from": "templates"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-hotkeys",
|
||||
"kind": "trigger",
|
||||
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||
},
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
|
||||
},
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||
}
|
||||
],
|
||||
"shell": [
|
||||
{
|
||||
"for": "after-wake",
|
||||
"slot": "normal",
|
||||
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
|
||||
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
|
||||
[UPower]
|
||||
UsePercentageForPolicy=true
|
||||
PercentageLow=15.0
|
||||
PercentageCritical=10.0
|
||||
PercentageAction=7.0
|
||||
CriticalPowerAction=Suspend
|
||||
AllowRiskyCriticalPowerAction=true
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The touchpad's settings, applied by X every time the device appears — at login and after every
|
||||
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
|
||||
# xinput after a resume as a named person on a guessed display.
|
||||
Section "InputClass"
|
||||
Identifier "asus-zephyrus-g14 touchpad"
|
||||
MatchIsTouchpad "on"
|
||||
Option "Tapping" "on"
|
||||
Option "NaturalScrolling" "true"
|
||||
Option "AccelSpeed" "0.15"
|
||||
EndSection
|
||||
@@ -1,7 +0,0 @@
|
||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||
sleep 2
|
||||
owner=$(ps -o user= -C i3 | head -n 1)
|
||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
true
|
||||
@@ -1,30 +0,0 @@
|
||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||
#
|
||||
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
|
||||
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
|
||||
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
|
||||
|
||||
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
|
||||
# screenshot grabs the pointer to select a region, which fails while the key is still held.
|
||||
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||
|
||||
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
|
||||
# external output.
|
||||
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
|
||||
|
||||
# The keyboard backlight's level, shown when it changes.
|
||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
|
||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||
#
|
||||
# The model's panel and touchpad.
|
||||
|
||||
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
|
||||
# is the display server's (autorandr, run at every session start).
|
||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
|
||||
|
||||
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
|
||||
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
@@ -1,10 +0,0 @@
|
||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
@@ -1,16 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
|
||||
#
|
||||
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
|
||||
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
|
||||
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
|
||||
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
|
||||
#
|
||||
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
|
||||
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
|
||||
# module's drop-ins on the sleep services ask for.
|
||||
#
|
||||
# A change here applies when the driver next loads: at the next boot.
|
||||
options nvidia NVreg_PreserveVideoMemoryAllocations=1
|
||||
options nvidia NVreg_DynamicPowerManagement=0x00
|
||||
@@ -1,6 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
|
||||
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
|
||||
# Applies when the module next loads: at the next boot.
|
||||
options video brightness_switch_enabled=0
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
|
||||
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
|
||||
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
|
||||
[Unit]
|
||||
ConditionKernelCommandLine=zephyrus.nvidia-powerd
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-hibernate.service nvidia-resume.service
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-suspend.service nvidia-resume.service
|
||||
@@ -1,5 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
|
||||
# move the panel without root.
|
||||
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
||||
@@ -1,11 +0,0 @@
|
||||
# audit-logger
|
||||
|
||||
The `audit-logger` module of the mesh catalogue: declares directories on the node.
|
||||
|
||||
## 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).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:trail}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:spool}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
@@ -47,20 +47,17 @@
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "spool",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
"mode": "0700"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# baserow
|
||||
|
||||
The `baserow` module of the mesh catalogue: runs the container image `baserow/baserow`.
|
||||
|
||||
## 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).
|
||||
|
||||
- `${dir:mesh-state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"password": "${secret:admin}",
|
||||
"host": "${bound:route:name}"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
DATABASE_HOST=${bound:postgres-database:at}
|
||||
DATABASE_PORT=${bound:postgres-database:port}
|
||||
DATABASE_NAME=${bound:postgres-database:as}
|
||||
DATABASE_USER=${bound:postgres-database:as}
|
||||
DATABASE_PASSWORD_FILE=/run/secrets/database
|
||||
DISABLE_EMBEDDED_PSQL=true
|
||||
BASEROW_PUBLIC_URL=https://${bound:route:name}
|
||||
@@ -46,54 +46,31 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
|
||||
"trusted": true,
|
||||
"default": "${bound:route:name}"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh",
|
||||
"unknown": "program"
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"mode": "0755",
|
||||
"owner": "9999:9999",
|
||||
"unknown": "program"
|
||||
"owner": "9999:9999"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"template": "files/server.env"
|
||||
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -125,10 +102,8 @@
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"template": "files/config.json",
|
||||
"merge": "json",
|
||||
"takes-config": ["host", "email", "url"],
|
||||
"protected": ["password"]
|
||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
||||
"merge": "json"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -147,11 +122,6 @@
|
||||
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
||||
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user