diff --git a/modules/fonts/README.md b/modules/fonts/README.md new file mode 100644 index 0000000..96d41c7 --- /dev/null +++ b/modules/fonts/README.md @@ -0,0 +1,99 @@ +# fonts + +The faces the workstations draw text with, as a module (novox/hq research 026/04, to-be 42 phase 2 +step 1). Assigned to the two workstations. + +## Owns + +| what | where | class (ADR 0182) | +|---|---|---| +| JetBrains Mono Nerd Font, for monospace: terminal, window manager, bar, launcher, prompt | package `ttf-jetbrains-mono-nerd` | package | +| Inter, for the interface: GTK, Qt, notifications | package `inter-font` | package | +| Nerd Fonts Symbols, the icon fallback for any face | package `ttf-nerd-fonts-symbols` | package | +| Noto, for serif and every other script | package `noto-fonts` | package | +| Noto Color Emoji | package `noto-fonts-emoji` | package | +| what `monospace`, `sans-serif`, `system-ui`, `serif` and `emoji` mean | `~/.config/fontconfig/conf.d/50-mesh-fonts.conf` | owned, whole | + +All five packages are in the distribution's official repositories. + +**Why the account's fontconfig directory, not `/etc/fonts/conf.d`.** The choice of faces is the +operator's taste on the operator's machine, and every program that draws for the operator runs as the +account. The account's directory is read by fontconfig's stock `50-user.conf`, before the +distribution's `60-latin.conf`, so the module's file decides the generic families without touching +anything the distribution owns. Root's tools and any other account keep the distribution's defaults. +A system file would need a symlink into `conf.d` the way the distribution does it, which the mesh +never makes (ADR 0012). + +**The file in short.** Each generic family prefers the decided face, bound `same` as the request. +Measured with fontconfig 2.18 on a workstation: a face added with the default (weak) binding loses to +`Noto Sans Mono` for a plain `monospace` request, because the distribution's own rule decides first. +The Nerd Fonts symbols and the colour emoji are appended last to every pattern, so an icon or emoji a +face lacks is still drawn, and they never displace a face that has the character. + +## Improves + +- **Every program agrees on monospace.** Today `monospace` is `Noto Sans Mono`, while the terminal, the + window manager and the bar use a hand-copied Hack. After the push, all of them can say the family + and get one face. +- **Configurations naming a font that is not installed stop falling back to a proportional face.** The + launcher's theme asks for `Iosevka Nerd Font` and its power menu for `JetBrains Mono Nerd Font`. + Neither is installed on the laptop, so fontconfig falls back to `sans-serif`. The file maps + `Hack Nerd Font`, `MesloLGS NF` and `Iosevka Nerd Font` to `monospace` and the old JetBrains name to + the new one, for as long as those families are not installed. Where one still is, it is used as + before. +- **Packages instead of copies.** The hand-copied files can go (below), and an upgrade of a face is a + package upgrade. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. + +| tool | what | +|---|---| +| `fonts_families` (r) | installed families: styles, file count, monospaced or not, and source (`package`, `account` for a hand copy, `other`) | +| `fonts_match` (r) | what each generic family resolves to, beside the decided face, with `all_as_decided`; or any patterns given | +| `fonts_glyph` (r) | which installed families have a character (given as itself or `U+F120`), and which face monospace and sans-serif draw it with | +| `fonts_sources` (r) | every hand-copied font file with its family, duplicates, and whether a package now provides the family; marks each `remove` with the reasons; lists packaged families with their package. Removes nothing | +| `fonts_config` (r) | whether the module's file is in place and loaded by fontconfig, and the account's other fontconfig files beside it | +| `fonts_cache_rebuild` (a) | rebuild the account's font cache, or with `system: true` the system's (through `sudo -n`) | + +## The one-off steps for the operator (ADR 0182) + +The mesh removes nothing it did not place. After the first push that assigns this module, on each +workstation: + +1. **Remove the hand-copied files** in `~/.local/share/fonts` that `fonts_sources` marks `remove`, then + run `fonts_cache_rebuild`. Measured on 2026-10-04: + - **laptop:** the four `HackNerdFont-*.ttf`; the four `MesloLGS NF *.ttf`; the four + `MesloLGS%20NF%20*.ttf`, which are the same bytes under URL-encoded names. Twelve files, 30 MB. + Nothing stays. + - **desktop:** the same twelve, plus `Iosevka-Nerd-Font-Complete.ttf` and + `JetBrains-Mono-Nerd-Font-Complete.ttf` (version-2 Nerd builds). `GrapeNuts-Regular.ttf` and + `Icomoon-Feather.ttf` came from a theme's repository. They are kept until the theme module decides + what it ships. + - Do this **after** the modules below name the new family, or the terminal and the bar fall back + through the file's aliases to `monospace`, which is the right face anyway. +2. **Nothing else.** No fontconfig file of the account's own exists today on either workstation. + +## Modules that must name the family + +Fonts are not a seat (research 026/04): this module says what the generic families mean, and each +desktop module names its family. Each switches to `JetBrainsMono Nerd Font` when it is written: + +| module | today, on both workstations | +|---|---| +| `xterm` | `~/.Xresources.d/xterm`: `*faceName: Hack Nerd Font` | +| `i3` | `~/.config/i3/config`: `font pango: Hack Nerd Font 11`, three times (the window manager and both bars) | +| the bar (`i3status-rust`) | takes the font i3's bar block names, above | +| `rofi` | `theme.rasi`: `Iosevka Nerd Font 10`; `powermenu.rasi`: `JetBrains Mono Nerd Font 10` and `Hack Nerd Font bold 32` | +| `dunst` | `font = Hack Nerd Font 10`, which becomes `Inter` (the interface face) | +| the prompt (`powerlevel10k`) | nothing: it uses whichever Nerd Font the terminal has | + +The DPI fixed in an X resource is the display server's setting (issue 168), not this module's. + +## Leaves as found + +- `noto-fonts-cjk` and `noto-fonts-extra`, installed on both workstations, and every other font + package. The module does not remove a package it did not install. +- The distribution's fontconfig files under `/etc/fonts`. +- The theme's two fonts on the desktop, above. diff --git a/modules/fonts/cmd/fonts-tools/fonts.go b/modules/fonts/cmd/fonts-tools/fonts.go new file mode 100644 index 0000000..c3a2868 --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/fonts.go @@ -0,0 +1,522 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// ConfigFile is where the module's fontconfig file is placed, under the account's home. fontconfig's +// stock 50-user.conf reads this directory, before the distribution's 60-latin.conf, so what it says +// is what the generic families mean. +const ConfigFile = ".config/fontconfig/conf.d/50-mesh-fonts.conf" + +// Decided is the face each generic family means (novox/hq research 026/04). +var Decided = map[string]string{ + "monospace": "JetBrainsMono Nerd Font", + "sans-serif": "Inter", + "system-ui": "Inter", + "serif": "Noto Serif", + "emoji": "Noto Color Emoji", +} + +// generics is the order fonts_match answers them in. +var generics = []string{"monospace", "sans-serif", "system-ui", "serif", "emoji"} + +// Retired are the monospace families the desktop's files named before the module; a hand-copied +// file of one of them is replaced by the decided face. +var Retired = []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font", "JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font"} + +// fontDirs are the account's own font directories, relative to its home. +var fontDirs = []string{".local/share/fonts", ".fonts"} + +const packagedRoot = "/usr/share/fonts/" + +// Family is one installed family. +type Family struct { + Family string `json:"family"` + Styles []string `json:"styles"` + Files int `json:"files"` + Monospace bool `json:"monospace"` + Source string `json:"source"` +} + +// FamiliesAnswer is what fonts_families answers. +type FamiliesAnswer struct { + Count int `json:"count"` + Families []Family `json:"families"` + Truncated bool `json:"truncated,omitempty"` +} + +func sourceOf(file string) string { + home := accountHome() + switch { + case home != "" && strings.HasPrefix(file, strings.TrimRight(home, "/")+"/"): + return "account" + case strings.HasPrefix(file, packagedRoot): + return "package" + } + return "other" +} + +func accountHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// Families lists what fc-list knows, by family. +func Families(contains string, limit int) (FamiliesAnswer, error) { + r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n"}}) + if err != nil { + return FamiliesAnswer{}, err + } + by := map[string]*Family{} + styles := map[string]map[string]bool{} + sources := map[string]map[string]bool{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) < 4 || f[0] == "" { + continue + } + name := f[0] + if contains != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(contains)) { + continue + } + if by[name] == nil { + by[name] = &Family{Family: name, Monospace: true} + styles[name], sources[name] = map[string]bool{}, map[string]bool{} + } + fam := by[name] + fam.Files++ + if f[1] != "" { + styles[name][f[1]] = true + } + // fontconfig's spacing: 100 mono, 110 charcell, 90 dual; absent is proportional. + if f[2] != "100" && f[2] != "110" && f[2] != "90" { + fam.Monospace = false + } + sources[name][sourceOf(f[3])] = true + } + out := FamiliesAnswer{Families: []Family{}} + for name, fam := range by { + fam.Styles = keys(styles[name]) + fam.Source = strings.Join(keys(sources[name]), "+") + out.Families = append(out.Families, *fam) + } + sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family }) + out.Count = len(out.Families) + if len(out.Families) > limit { + out.Families, out.Truncated = out.Families[:limit], true + } + return out, nil +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// Resolved is what one pattern resolves to. +type Resolved struct { + Asked string `json:"asked"` + Family string `json:"family"` + Style string `json:"style"` + File string `json:"file"` + Source string `json:"source"` + Decided string `json:"decided,omitempty"` + AsDecided *bool `json:"as_decided,omitempty"` +} + +func checkPattern(p string) error { + if strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\n\t") { + return fmt.Errorf("%q is not a fontconfig pattern", p) + } + return nil +} + +func resolve(pattern string) (Resolved, error) { + if err := checkPattern(pattern); err != nil { + return Resolved{}, err + } + r, err := call(Cmd{Name: "fc-match", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{file}", pattern}}) + if err != nil { + return Resolved{}, err + } + f := strings.Split(strings.TrimSpace(r.Stdout), "\t") + if len(f) < 3 || f[0] == "" { + return Resolved{}, fmt.Errorf("fc-match answered nothing usable for %q: %q", pattern, strings.TrimSpace(r.Stdout)) + } + return Resolved{Asked: pattern, Family: f[0], Style: f[1], File: f[2], Source: sourceOf(f[2])}, nil +} + +// MatchAnswer is what fonts_match answers. +type MatchAnswer struct { + Resolved []Resolved `json:"resolved"` + // AllAsDecided is set when only the generics were asked, and says whether each is the decided face. + AllAsDecided *bool `json:"all_as_decided,omitempty"` +} + +// Match resolves the generic families, or the patterns given. +func Match(patterns []string) (MatchAnswer, error) { + generic := len(patterns) == 0 + if generic { + patterns = generics + } + out := MatchAnswer{Resolved: []Resolved{}} + all := true + for _, p := range patterns { + r, err := resolve(p) + if err != nil { + return MatchAnswer{}, err + } + if want, ok := Decided[p]; ok { + agree := r.Family == want + r.Decided, r.AsDecided = want, &agree + all = all && agree + } + out.Resolved = append(out.Resolved, r) + } + if generic { + out.AllAsDecided = &all + } + return out, nil +} + +// codePoint reads one character, or a code point written U+XXXX or 0xXXXX. +func codePoint(s string) (rune, error) { + s = strings.TrimSpace(s) + if utf8.RuneCountInString(s) == 1 { + r, _ := utf8.DecodeRuneInString(s) + return r, nil + } + up := strings.ToUpper(s) + for _, prefix := range []string{"U+", "0X"} { + if strings.HasPrefix(up, prefix) { + n, err := strconv.ParseUint(up[len(prefix):], 16, 32) + if err != nil || n == 0 || n > utf8.MaxRune { + return 0, fmt.Errorf("%q is not a code point", s) + } + return rune(n), nil + } + } + return 0, fmt.Errorf("give one character, or its code point as U+XXXX or 0xXXXX, not %q", s) +} + +// GlyphAnswer is what fonts_glyph answers. +type GlyphAnswer struct { + CodePoint string `json:"code_point"` + Character string `json:"character"` + Count int `json:"count"` + Families []Family `json:"families"` + DrawnBy map[string]string `json:"drawn_by"` + Truncated bool `json:"truncated,omitempty"` +} + +// Glyph answers which fonts have a character and which face the generics would draw it with. +func Glyph(s string) (GlyphAnswer, error) { + cp, err := codePoint(s) + if err != nil { + return GlyphAnswer{}, err + } + hx := strconv.FormatInt(int64(cp), 16) + r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n", ":charset=" + hx}}) + if err != nil { + return GlyphAnswer{}, err + } + by := map[string]*Family{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) < 4 || f[0] == "" { + continue + } + if by[f[0]] == nil { + by[f[0]] = &Family{Family: f[0], Monospace: f[2] == "100" || f[2] == "110" || f[2] == "90", Source: sourceOf(f[3]), Styles: []string{}} + } + by[f[0]].Files++ + } + out := GlyphAnswer{CodePoint: fmt.Sprintf("U+%04X", cp), Character: string(cp), Families: []Family{}, DrawnBy: map[string]string{}} + for _, f := range by { + out.Families = append(out.Families, *f) + } + sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family }) + out.Count = len(out.Families) + if len(out.Families) > 200 { + out.Families, out.Truncated = out.Families[:200], true + } + for _, g := range []string{"monospace", "sans-serif"} { + res, err := resolve(g + ":charset=" + hx) + if err != nil { + return GlyphAnswer{}, err + } + out.DrawnBy[g] = res.Family + } + return out, nil +} + +// HandFile is one font file copied into the account's home. +type HandFile struct { + File string `json:"file"` + Family string `json:"family"` + Style string `json:"style"` + Bytes int64 `json:"bytes"` + Remove bool `json:"remove"` + Why []string `json:"why,omitempty"` + sum string +} + +// Packaged is a family a package installs. +type Packaged struct { + Family string `json:"family"` + Package string `json:"package"` +} + +// SourcesAnswer is what fonts_sources answers. +type SourcesAnswer struct { + Hand []HandFile `json:"hand_copied"` + HandBytes int64 `json:"hand_copied_bytes"` + RemoveCount int `json:"removable"` + Packaged []Packaged `json:"packaged"` + Note string `json:"note"` +} + +var fontExt = map[string]bool{".ttf": true, ".otf": true, ".ttc": true, ".otc": true, ".pfb": true, ".pcf": true, ".woff": true, ".woff2": true, ".bdf": true} + +// handFiles walks the account's font directories. +func handFiles(home string) ([]HandFile, error) { + out := []HandFile{} + for _, d := range fontDirs { + root := filepath.Join(home, d) + err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error { + if err != nil { + if p == root && os.IsNotExist(err) { + return filepath.SkipDir + } + return err + } + if e.IsDir() || !fontExt[strings.ToLower(filepath.Ext(p))] { + return nil + } + info, err := e.Info() + if err != nil { + return err + } + f, err := os.Open(p) + if err != nil { + return err + } + h := sha256.New() + _, err = io.Copy(h, f) + f.Close() + if err != nil { + return err + } + out = append(out, HandFile{File: p, Bytes: info.Size(), sum: hex.EncodeToString(h.Sum(nil))}) + return nil + }) + if err != nil && err != filepath.SkipDir { + return nil, fmt.Errorf("reading %s: %w", root, err) + } + } + sort.Slice(out, func(i, k int) bool { return out[i].File < out[k].File }) + return out, nil +} + +// Sources says which font files were copied by hand, which of them can go, and which families +// packages install. +func Sources() (SourcesAnswer, error) { + home := accountHome() + hand, err := handFiles(home) + if err != nil { + return SourcesAnswer{}, err + } + out := SourcesAnswer{Hand: hand, Packaged: []Packaged{}, + Note: "Nothing is removed by this tool. The mesh removes nothing it did not place (ADR 0182): a file marked remove is for the operator to delete, once; then run fonts_cache_rebuild."} + if len(hand) > 0 { + args := []string{"--format", "%{file}\t%{family[0]}\t%{style[0]}\n"} + for _, h := range hand { + args = append(args, h.File) + } + r, err := call(Cmd{Name: "fc-scan", Args: args}) + if err != nil { + return SourcesAnswer{}, err + } + named := map[string][2]string{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) >= 3 { + if _, seen := named[f[0]]; !seen { + named[f[0]] = [2]string{f[1], f[2]} + } + } + } + for i := range out.Hand { + n := named[out.Hand[i].File] + out.Hand[i].Family, out.Hand[i].Style = n[0], n[1] + } + } + // Families the packages install, each with one file to ask pacman about. + r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{file}\n"}}) + if err != nil { + return SourcesAnswer{}, err + } + fileOf := map[string]string{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) >= 2 && f[0] != "" && strings.HasPrefix(f[1], packagedRoot) { + if _, seen := fileOf[f[0]]; !seen { + fileOf[f[0]] = f[1] + } + } + } + owner := map[string]string{} + if len(fileOf) > 0 { + args := []string{"-Qo"} + for _, fam := range keys(boolSet(fileOf)) { + args = append(args, fileOf[fam]) + } + // pacman exits 1 when one file is unowned and still answers the rest: read what it said. + r := run(Cmd{Name: "pacman", Args: args}) + if r.Error != "" { + return SourcesAnswer{}, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r) + } + for _, l := range lines(r.Stdout) { + // "/usr/share/fonts/x.ttf is owned by noto-fonts 1:2026.08.01-1" + if i := strings.Index(l, " is owned by "); i > 0 { + pkg := strings.Fields(l[i+len(" is owned by "):]) + if len(pkg) > 0 { + owner[l[:i]] = pkg[0] + } + } + } + } + for _, fam := range keys(boolSet(fileOf)) { + pkg := owner[fileOf[fam]] + if pkg == "" { + pkg = "(no package)" + } + out.Packaged = append(out.Packaged, Packaged{Family: fam, Package: pkg}) + } + // Which hand-copied files can go, and why. + firstOf := map[string]string{} + for i := range out.Hand { + h := &out.Hand[i] + if prev, dup := firstOf[h.sum]; dup { + h.Why = append(h.Why, "the same bytes as "+filepath.Base(prev)) + } else { + firstOf[h.sum] = h.File + } + if strings.Contains(filepath.Base(h.File), "%20") { + h.Why = append(h.Why, "a URL-encoded copy of a name") + } + if pkg := owner[fileOf[h.Family]]; h.Family != "" && pkg != "" { + h.Why = append(h.Why, "its family is installed by the package "+pkg) + } + for _, r := range Retired { + if h.Family == r { + h.Why = append(h.Why, "a monospace face the desktop named before the fonts module; "+Decided["monospace"]+" replaces it once the terminal, window manager, bar and launcher name that family") + break + } + } + h.Remove = len(h.Why) > 0 + out.HandBytes += h.Bytes + if h.Remove { + out.RemoveCount++ + } + } + return out, nil +} + +func boolSet(m map[string]string) map[string]bool { + out := map[string]bool{} + for k := range m { + out[k] = true + } + return out +} + +// ConfigAnswer is what fonts_config answers. +type ConfigAnswer struct { + Path string `json:"path"` + Present bool `json:"present"` + Loaded bool `json:"loaded"` + Bytes int `json:"bytes,omitempty"` + Others []string `json:"account_files_beside_it"` + Note string `json:"note,omitempty"` +} + +// Config says whether the module's file is in place and loaded. +func Config() (ConfigAnswer, error) { + path := filepath.Join(accountHome(), ConfigFile) + out := ConfigAnswer{Path: path, Others: []string{}} + if b, err := os.ReadFile(path); err == nil { + out.Present, out.Bytes = true, len(b) + } else if !os.IsNotExist(err) { + return ConfigAnswer{}, fmt.Errorf("reading %s: %w", path, err) + } + entries, _ := os.ReadDir(filepath.Dir(path)) + for _, e := range entries { + if p := filepath.Join(filepath.Dir(path), e.Name()); p != path { + out.Others = append(out.Others, p) + } + } + if b, err := os.ReadFile(filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")); err == nil && len(b) > 0 { + out.Others = append(out.Others, filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")) + } + r, err := call(Cmd{Name: "fc-conflist"}) + if err != nil { + return ConfigAnswer{}, err + } + for _, l := range lines(r.Stdout) { + // "+ /path: description" for a file in use, "- /path" for one skipped + if strings.HasPrefix(l, "+ "+path+":") || strings.TrimSpace(l) == "+ "+path { + out.Loaded = true + } + } + if !out.Present { + out.Note = "the file is not in place: the fonts module is not assigned to this machine, or its push has not reached it" + } else if !out.Loaded { + out.Note = "the file is in place and fontconfig does not load it: check that /etc/fonts/conf.d/50-user.conf is enabled" + } + if len(out.Others) > 0 { + out.Note = strings.TrimSpace(out.Note + " The other files are the account's own: they are read too, and one sorting after 50-mesh-fonts.conf can override it.") + } + return out, nil +} + +// RebuildAnswer is what fonts_cache_rebuild answers. +type RebuildAnswer struct { + Scope string `json:"scope"` + ElapsedMS int64 `json:"elapsed_ms"` + Output string `json:"output,omitempty"` +} + +// CacheRebuild rebuilds the account's font cache, or the system's. +func CacheRebuild(system bool) (RebuildAnswer, error) { + c := Cmd{Name: "fc-cache", Args: []string{"-f"}} + scope := "account" + if system { + c, scope = Cmd{Name: "fc-cache", Args: []string{"-s", "-f"}, Root: true}, "system" + } + start := time.Now() + r, err := call(c) + if err != nil { + return RebuildAnswer{}, err + } + return RebuildAnswer{Scope: scope, ElapsedMS: time.Since(start).Milliseconds(), Output: tail(strings.TrimSpace(r.Stdout+r.Stderr), 4000)}, nil +} diff --git a/modules/fonts/cmd/fonts-tools/fonts_test.go b/modules/fonts/cmd/fonts-tools/fonts_test.go new file mode 100644 index 0000000..8fe9b19 --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/fonts_test.go @@ -0,0 +1,302 @@ +package main + +import ( + "encoding/xml" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTheManifestInstallsTheFiveDecidedFacesAndOwnsOneAccountFile(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "fonts") + want := "inter-font,noto-fonts,noto-fonts-emoji,ttf-jetbrains-mono-nerd,ttf-nerd-fonts-symbols" + if got := strings.Join(m.packages(), ","); got != want { + t.Errorf("packages %s, want %s", got, want) + } + files := 0 + for _, r := range m.Resources { + if r["type"] == "file" { + files++ + } + if r["type"] == "service" || r["type"] == "directory" { + t.Errorf("a font needs no %s: %v", r["type"], r["id"]) + } + } + f := m.resource("defaults") + if files != 1 || f == nil { + t.Fatalf("one owned file, got %d", files) + } + if f["path"] != "${machine:account-home}/"+ConfigFile || f["owner"] != "${machine:account}" || f["mode"] != "0644" { + t.Errorf("the file is the account's, at %s: %v", ConfigFile, f) + } + if _, into := f["into"]; into { + t.Error("the file is the module's whole, not written into") + } +} + +// fontconfig's document, as far as the module's file uses it. +type fcDoc struct { + Aliases []struct { + Binding string `xml:"binding,attr"` + Family string `xml:"family"` + Prefer []string `xml:"prefer>family"` + Accept []string `xml:"accept>family"` + } `xml:"alias"` + Matches []struct { + Target string `xml:"target,attr"` + Edits []struct { + Name string `xml:"name,attr"` + Mode string `xml:"mode,attr"` + String string `xml:"string"` + } `xml:"edit"` + } `xml:"match"` +} + +func TestTheFontconfigFileMapsEachGenericToTheDecidedFaceStronglyAndFallsBackLast(t *testing.T) { + m := readManifest(t) + content, _ := m.resource("defaults")["content"].(string) + if !strings.HasPrefix(content, " 0 { + firstPrefer[a.Family] = a.Prefer[0] + if a.Binding != "same" { + t.Errorf("%s is bound %q: a weakly added face loses to the distribution's choice", a.Family, a.Binding) + } + } + } + for generic, face := range Decided { + if firstPrefer[generic] != face { + t.Errorf("%s prefers %q, decided %q", generic, firstPrefer[generic], face) + } + } + // The retired families lead to monospace, and are placed before the monospace rule. + mono := strings.Index(strings.Join(order, "|"), "|monospace|") + for _, r := range []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font"} { + at := strings.Index(strings.Join(order, "|"), r) + if at < 0 || at > mono { + t.Errorf("%s is not mapped to monospace before the monospace rule", r) + } + } + if len(doc.Matches) != 1 || doc.Matches[0].Target != "pattern" { + t.Fatalf("one pattern match for the fallbacks: %+v", doc.Matches) + } + got := []string{} + for _, e := range doc.Matches[0].Edits { + if e.Name != "family" || e.Mode != "append_last" { + t.Errorf("a fallback is appended last, never prepended: %+v", e) + } + got = append(got, e.String) + } + if strings.Join(got, ",") != "Symbols Nerd Font,Noto Color Emoji" { + t.Errorf("fallbacks %v", got) + } +} + +func TestFamiliesGroupsFilesAndTellsAHandCopiedFaceFromAPackagedOne(t *testing.T) { + t.Setenv("MESH_OPERATOR_HOME", "/home/op") + using(t, func(line string, c Cmd) Result { + return ok("Inter\tRegular\t\t/usr/share/fonts/inter/Inter.ttc\n" + + "Inter\tBold\t\t/usr/share/fonts/inter/Inter-Bold.ttc\n" + + "Hack Nerd Font\tRegular\t100\t/home/op/.local/share/fonts/Hack.ttf\n" + + "Noto Sans Mono\tRegular\t100\t/usr/share/fonts/noto/NotoSansMono.ttf\n") + }) + got, err := Families("", 10) + if err != nil || got.Count != 3 { + t.Fatalf("%+v %v", got, err) + } + if f := got.Families[0]; f.Family != "Hack Nerd Font" || f.Source != "account" || !f.Monospace { + t.Errorf("%+v", f) + } + if f := got.Families[1]; f.Family != "Inter" || f.Files != 2 || f.Monospace || f.Source != "package" || len(f.Styles) != 2 { + t.Errorf("%+v", f) + } + got, _ = Families("noto", 10) + if got.Count != 1 { + t.Errorf("filtered: %+v", got) + } + got, _ = Families("", 1) + if !got.Truncated || len(got.Families) != 1 || got.Count != 3 { + t.Errorf("bounded: %+v", got) + } +} + +func TestMatchSaysWhetherEachGenericIsTheDecidedFace(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + switch c.Args[len(c.Args)-1] { + case "monospace": + return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf") + case "sans-serif", "system-ui": + return ok("Noto Sans\tRegular\t/usr/share/fonts/noto/NotoSans-Regular.ttf") + case "serif": + return ok("Noto Serif\tRegular\t/usr/share/fonts/noto/NotoSerif-Regular.ttf") + } + return ok("Noto Color Emoji\tRegular\t/usr/share/fonts/noto/NotoColorEmoji.ttf") + }) + got, err := Match(nil) + if err != nil || len(got.Resolved) != 5 || got.AllAsDecided == nil || *got.AllAsDecided { + t.Fatalf("%+v %v", got, err) + } + if r := got.Resolved[0]; !*r.AsDecided || r.Source != "package" { + t.Errorf("monospace: %+v", r) + } + if r := got.Resolved[1]; *r.AsDecided || r.Decided != "Inter" { + t.Errorf("sans-serif is not Inter here: %+v", r) + } + if len(f.asked) != 5 { + t.Errorf("one fc-match per generic: %v", f.lines()) + } + got, _ = Match([]string{"Inter:bold"}) + if got.AllAsDecided != nil || got.Resolved[0].AsDecided != nil { + t.Errorf("a pattern of the caller's own has no decided face: %+v", got) + } + if _, err := Match([]string{"--help"}); err == nil { + t.Error("an option as a pattern") + } +} + +func TestMatchAnEmptyAnswerIsAnError(t *testing.T) { + using(t, func(string, Cmd) Result { return ok("") }) + if _, err := Match(nil); err == nil { + t.Fatal("an empty fc-match answer was read as a face") + } +} + +func TestGlyphReadsACharacterOrACodePointAndAsksForIt(t *testing.T) { + for in, want := range map[string]rune{"\uf120": 0xf120, "U+F120": 0xf120, "0x1f600": 0x1f600, "a": 'a', "é": 'é'} { + got, err := codePoint(in) + if err != nil || got != want { + t.Errorf("%q: %x %v", in, got, err) + } + } + for _, bad := range []string{"ab", "U+ZZ", "U+0", "120"} { + if _, err := codePoint(bad); err == nil { + t.Errorf("%q was read as a code point", bad) + } + } + f := using(t, func(line string, c Cmd) Result { + if c.Name == "fc-list" { + return ok("Symbols Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/SymbolsNerdFont-Regular.ttf\n" + + "JetBrainsMono Nerd Font\tBold\t100\t/usr/share/fonts/TTF/a.ttf\nJetBrainsMono Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/b.ttf\n") + } + return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/b.ttf") + }) + got, err := Glyph("U+F120") + if err != nil || got.Count != 2 || got.CodePoint != "U+F120" || got.DrawnBy["monospace"] != "JetBrainsMono Nerd Font" { + t.Fatalf("%+v %v", got, err) + } + if !strings.Contains(f.lines()[0], ":charset=f120") || !strings.Contains(f.lines()[1], "monospace:charset=f120") { + t.Errorf("asked %v", f.lines()) + } +} + +func TestSourcesMarksDuplicatesEncodedNamesPackagedAndRetiredFacesForRemoval(t *testing.T) { + home := t.TempDir() + t.Setenv("MESH_OPERATOR_HOME", home) + dir := filepath.Join(home, ".local/share/fonts") + _ = os.MkdirAll(dir, 0o755) + write := func(name, body string) string { + p := filepath.Join(dir, name) + _ = os.WriteFile(p, []byte(body), 0o644) + return p + } + meslo := write("MesloLGS NF Regular.ttf", "meslo") + encoded := write("MesloLGS%20NF%20Regular.ttf", "meslo") + grape := write("GrapeNuts-Regular.ttf", "grape") + noto := write("NotoSans-Copy.ttf", "noto") + _ = os.WriteFile(filepath.Join(dir, ".uuid"), []byte("x"), 0o644) + f := using(t, func(line string, c Cmd) Result { + switch c.Name { + case "fc-scan": + return ok(meslo + "\tMesloLGS NF\tRegular\n" + encoded + "\tMesloLGS NF\tRegular\n" + grape + "\tGrape Nuts\tRegular\n" + noto + "\tNoto Sans\tRegular\n") + case "fc-list": + return ok("Noto Sans\t/usr/share/fonts/noto/NotoSans-Regular.ttf\nNoto Sans\t/usr/share/fonts/noto/NotoSans-Bold.ttf\nInter\t/usr/share/fonts/inter/Inter.ttc\nMesloLGS NF\t" + meslo + "\n") + case "pacman": + return Result{Status: 1, Stdout: "/usr/share/fonts/noto/NotoSans-Regular.ttf is owned by noto-fonts 1:2026.08.01-1\n", Stderr: "error: No package owns /usr/share/fonts/inter/Inter.ttc\n"} + } + return Result{Status: 9} + }) + got, err := Sources() + if err != nil { + t.Fatal(err) + } + if len(got.Hand) != 4 { + t.Fatalf("four font files, the .uuid is not one: %+v", got.Hand) + } + by := map[string]HandFile{} + for _, h := range got.Hand { + by[filepath.Base(h.File)] = h + } + if h := by["GrapeNuts-Regular.ttf"]; h.Remove || h.Family != "Grape Nuts" { + t.Errorf("a face nothing replaces is kept: %+v", h) + } + if h := by["MesloLGS%20NF%20Regular.ttf"]; !h.Remove || len(h.Why) < 3 { + t.Errorf("an encoded duplicate of a retired face: %+v", h) + } + if h := by["NotoSans-Copy.ttf"]; !h.Remove || !strings.Contains(strings.Join(h.Why, ";"), "noto-fonts") { + t.Errorf("a face a package installs: %+v", h) + } + if got.RemoveCount != 3 || got.HandBytes != int64(len("meslo")*2+len("grape")+len("noto")) { + t.Errorf("%d removable, %d bytes", got.RemoveCount, got.HandBytes) + } + pk := map[string]string{} + for _, p := range got.Packaged { + pk[p.Family] = p.Package + } + if pk["Noto Sans"] != "noto-fonts" || pk["Inter"] != "(no package)" || pk["MesloLGS NF"] != "" { + t.Errorf("packaged %v", pk) + } + for _, l := range f.lines() { + if strings.Contains(l, "rm ") || strings.HasPrefix(l, "sudo") { + t.Errorf("sources only reads: %s", l) + } + } +} + +func TestConfigSaysWhetherTheFileIsInPlaceAndLoaded(t *testing.T) { + home := t.TempDir() + t.Setenv("MESH_OPERATOR_HOME", home) + path := filepath.Join(home, ConfigFile) + using(t, func(string, Cmd) Result { return ok("+ " + path + ": The mesh\n- /etc/fonts/conf.d/x.conf\n") }) + got, err := Config() + if err != nil || got.Present || !strings.Contains(got.Note, "not in place") { + t.Fatalf("absent: %+v %v", got, err) + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(""), 0o644) + _ = os.WriteFile(filepath.Join(filepath.Dir(path), "99-mine.conf"), []byte("x"), 0o644) + got, err = Config() + if err != nil || !got.Present || !got.Loaded || len(got.Others) != 1 { + t.Fatalf("present: %+v %v", got, err) + } +} + +func TestCacheRebuildIsTheAccountsUnlessTheSystemIsAskedWhichNeedsRoot(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok("") }) + if got, err := CacheRebuild(false); err != nil || got.Scope != "account" { + t.Fatal(got, err) + } + if got, err := CacheRebuild(true); err != nil || got.Scope != "system" { + t.Fatal(got, err) + } + if l := f.lines(); l[0] != "fc-cache -f" || l[1] != "sudo -n fc-cache -s -f" { + t.Errorf("%v", l) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "sudo: a password is required"} }) + if _, err := CacheRebuild(true); err == nil || !strings.Contains(err.Error(), "sudo -n refused") { + t.Errorf("a refused escalation: %v", err) + } +} diff --git a/modules/fonts/cmd/fonts-tools/kit.go b/modules/fonts/cmd/fonts-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/kit.go @@ -0,0 +1,352 @@ +package main + +// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd, +// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it +// keeps, and names a failure. A module is built from its own directory, so the file is copied rather +// than shared; a change to one copy is made to all eight. +// +// The rules it holds (novox/hq research 026/05, to-be 38 WP4): +// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that +// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such; +// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it +// started when it takes longer; +// - each stream is kept to 256 KiB, and the answer says when it was cut; +// - a failure is an error with what went wrong in it, never an empty answer. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds every command is held to. +const ( + CallTimeout = 20 * time.Second + MostOutput = 256 << 10 +) + +// Cmd is one command a tool runs. +type Cmd struct { + Name string + Args []string + // Stdin is written to the command's standard input when not empty. + Stdin string + // Env is added to this process's own environment. + Env []string + // Root says the command needs root: it is run through `sudo -n` when this process is not root. + Root bool + // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer. + Timeout time.Duration + // Detached is for a program that forks a child which outlives it, as xclip does to keep the + // selection: its streams go to files, because a pipe the child inherits would hold the call open + // until the child exits. + Detached bool +} + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Status int `json:"status"` + // Error is why it did not run to an answer: "not-found" when the program is not there, + // "timeout" when it was ended for taking too long, else the spawn error. + Error string `json:"error,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// Runner runs a command. Tests replace it; nothing else does. +type Runner func(Cmd) Result + +var ( + run Runner = execRun + euid = os.Geteuid +) + +// argv is the command as it is run: through sudo without a prompt when it needs root and this +// process is not root. +func argv(c Cmd) (string, []string) { + if c.Root && euid() != 0 { + return "sudo", append([]string{"-n", c.Name}, c.Args...) + } + return c.Name, c.Args +} + +// bounded keeps the first MostOutput bytes written to it and notes that more came. +type bounded struct { + b bytes.Buffer + cut bool +} + +func (w *bounded) Write(p []byte) (int, error) { + room := MostOutput - w.b.Len() + if room <= 0 { + w.cut = w.cut || len(p) > 0 + return len(p), nil + } + if len(p) > room { + w.b.Write(p[:room]) + w.cut = true + return len(p), nil + } + return w.b.Write(p) +} + +func execRun(c Cmd) Result { + timeout := c.Timeout + if timeout <= 0 { + timeout = CallTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name, args := argv(c) + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...) + if !c.Detached { + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + } + cmd.WaitDelay = 2 * time.Second + if c.Stdin != "" { + cmd.Stdin = strings.NewReader(c.Stdin) + } + var out, errs bounded + var outFile, errFile *os.File + if c.Detached { + var err error + if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(outFile.Name()) + defer outFile.Close() + if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(errFile.Name()) + defer errFile.Close() + cmd.Stdout, cmd.Stderr = outFile, errFile + } else { + cmd.Stdout, cmd.Stderr = &out, &errs + } + err := cmd.Run() + if c.Detached { + for _, f := range []struct { + file *os.File + into *bounded + }{{outFile, &out}, {errFile, &errs}} { + if _, e := f.file.Seek(0, io.SeekStart); e == nil { + _, _ = io.Copy(f.into, f.file) + } + } + } + r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + r.Status, r.Error = 124, "timeout" + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist): + r.Status, r.Error = 127, "not-found" + case errors.As(err, &exit): + r.Status = exit.ExitCode() + default: + r.Status, r.Error = 127, err.Error() + } + return r +} + +// call runs a command and answers its result, or an error naming what went wrong. +func call(c Cmd) (Result, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, nil + } + return r, failure(c, r) +} + +// failure names how a command failed: not installed, refused escalation, too slow, or its exit +// status with the end of what it said. +func failure(c Cmd, r Result) error { + program, _ := argv(c) + switch { + case r.Error == "not-found" && program == "sudo": + return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name) + case r.Error == "not-found": + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case r.Error == "timeout": + limit := c.Timeout + if limit <= 0 { + limit = CallTimeout + } + return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit) + case r.Error != "": + return fmt.Errorf("%s did not run: %s", c.Name, r.Error) + case program == "sudo" && strings.Contains(r.Stderr, "command not found"): + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"): + return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)", + c.Name, firstLine(r.Stderr)) + } + said := tail(strings.TrimSpace(r.Stderr), 2000) + if said == "" { + said = tail(strings.TrimSpace(r.Stdout), 2000) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +// lines are a command's output lines, blank ones dropped. +func lines(s string) []string { + out := []string{} + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, strings.TrimRight(l, "\r")) + } + } + return out +} + +// Arguments, read the way a tool's JSON arguments arrive. + +func text(args map[string]any, key string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return "", fmt.Errorf("%s is required", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("%s must not be empty", key) + } + return s, nil +} + +func optText(args map[string]any, key, def string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return def, nil + } + return s, nil +} + +// optWhole reads a whole number, defaulted, refused below least and held to most. +func optWhole(args map[string]any, key string, def, least, most int) (int, error) { + v, ok := args[key] + if !ok || 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 must be a number", key) + } + } + if f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +func optFlag(args map[string]any, key string, def bool) (bool, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +func optList(args map[string]any, key string) ([]string, error) { + v, ok := args[key] + if !ok || v == nil { + return nil, nil + } + items, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", key) + } + out := make([]string, 0, len(items)) + for _, it := range items { + s, ok := it.(string) + if !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("%s must be a list of non-empty strings", key) + } + out = append(out, s) + } + return out, nil +} + +// oneOf refuses a value outside a closed set. +func oneOf(key, value string, allowed ...string) error { + for _, a := range allowed { + if value == a { + return nil + } + } + return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value) +} + +// plainName refuses a name that could be read as an option or carries a path or a space: package, +// snap, application and printer names never do. +func plainName(key, value string) error { + if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") { + return fmt.Errorf("%s %q is not a plain name", key, value) + } + return nil +} diff --git a/modules/fonts/cmd/fonts-tools/kit_test.go b/modules/fonts/cmd/fonts-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/kit_test.go @@ -0,0 +1,147 @@ +package main + +// Tests of kit.go, the same in each workstation module. + +import ( + "strings" + "testing" + "time" +) + +// fake records the commands asked and answers each from a function of the command line. +type fake struct { + asked []Cmd + answer func(line string, c Cmd) Result +} + +func (f *fake) runner() Runner { + return func(c Cmd) Result { + f.asked = append(f.asked, c) + name, args := argv(c) + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if f.answer == nil { + return Result{} + } + return f.answer(line, c) + } +} + +func (f *fake) lines() []string { + out := []string{} + for _, c := range f.asked { + name, args := argv(c) + out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " "))) + } + return out +} + +// using installs a fake runner and a non-root uid for one test. +func using(t *testing.T, answer func(line string, c Cmd) Result) *fake { + t.Helper() + f := &fake{answer: answer} + wasRun, wasUID := run, euid + run, euid = f.runner(), func() int { return 1000 } + t.Cleanup(func() { run, euid = wasRun, wasUID }) + return f +} + +func ok(stdout string) Result { return Result{Stdout: stdout} } + +func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" { + t.Fatalf("not root: %s %v", name, args) + } + if name, _ := argv(Cmd{Name: "x"}); name != "x" { + t.Fatalf("a read is run as the account: %s", name) + } + euid = func() int { return 0 } + if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" { + t.Fatalf("as root no sudo: %s", name) + } +} + +func TestKitAFailureIsNamedByHowItFailed(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + cases := []struct { + c Cmd + r Result + want string + }{ + {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"}, + {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"}, + {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"}, + {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"}, + } + for _, k := range cases { + err := failure(k.c, k.r) + if err == nil || !strings.Contains(err.Error(), k.want) { + t.Errorf("%+v: %v, want %q", k.r, err, k.want) + } + } +} + +func TestKitOutputIsBoundedAndSaysSo(t *testing.T) { + var w bounded + big := strings.Repeat("a", MostOutput+10) + n, _ := w.Write([]byte(big)) + if n != len(big) || w.b.Len() != MostOutput || !w.cut { + t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut) + } +} + +func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) { + r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}}) + if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("%+v", r) + } + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond}) + if r.Error != "timeout" { + t.Fatalf("a slow command: %+v", r) + } + r = execRun(Cmd{Name: "no-such-program-anywhere"}) + if r.Error != "not-found" { + t.Fatalf("a missing program: %+v", r) + } + r = execRun(Cmd{Name: "cat", Stdin: "given"}) + if r.Stdout != "given" { + t.Fatalf("stdin: %+v", r) + } + start := time.Now() + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true}) + if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second { + t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start)) + } +} + +func TestKitArgumentsAreReadStrictly(t *testing.T) { + args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if _, err := text(args, "missing"); err == nil { + t.Error("a missing required string") + } + if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 { + t.Errorf("held to most: %d", n) + } + if _, err := optWhole(args, "n", 1, 6, 9); err == nil { + t.Error("below least") + } + if _, err := optWhole(args, "f", 1, 0, 9); err == nil { + t.Error("a fraction") + } + if l, _ := optList(args, "l"); len(l) != 2 { + t.Errorf("list: %v", l) + } + if b, _ := optFlag(args, "b", false); !b { + t.Error("flag") + } + if err := plainName("name", "--all"); err == nil { + t.Error("an option as a name") + } +} diff --git a/modules/fonts/cmd/fonts-tools/main.go b/modules/fonts/cmd/fonts-tools/main.go new file mode 100644 index 0000000..4d00345 --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/main.go @@ -0,0 +1,117 @@ +// The fonts module's tools (novox/hq research 026/04, 026/05): what faces the account has, what the +// generic families resolve to, which face draws a character, which font files were copied by hand +// and may go, and rebuilding the font cache. A Go bundle the node's runtime launches and speaks MCP +// to over stdio (ADR 0188, ADR 0193); it runs as the operator account. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// providedBy names what installs a program the tools run, for a failure that says so. +var providedBy = map[string]string{ + "fc-list": "the fontconfig package", + "fc-match": "the fontconfig package", + "fc-scan": "the fontconfig package", + "fc-cache": "the fontconfig package", + "fc-conflist": "the fontconfig package", + "pacman": "this is not an Arch machine", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "fonts_families", + Description: "The font families installed for the operator account, each with its styles, how many files, " + + "whether it is monospaced, and where it comes from: package (under /usr/share/fonts), account (copied into " + + "the home by hand) or other. (r)", + Input: map[string]any{ + "contains": map[string]any{"type": "string", "description": "only families whose name contains this, any case"}, + "limit": map[string]any{"type": "integer", "description": "at most this many families (default 500, at most 2000)"}, + }, + Run: func(args map[string]any) (any, error) { + contains, err := optText(args, "contains", "") + if err != nil { + return nil, err + } + limit, err := optWhole(args, "limit", 500, 1, 2000) + if err != nil { + return nil, err + } + return Families(contains, limit) + }, + }, + { + Name: "fonts_match", + Description: "What the generic families resolve to for this account: monospace, sans-serif, system-ui, serif and " + + "emoji by default, or the patterns given. Each answer names the face, its file, the face the fonts module " + + "decided on, and whether they agree. (r)", + Input: map[string]any{ + "patterns": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "fontconfig patterns to resolve instead, such as \"monospace:bold\" or \"Inter\""}, + }, + Run: func(args map[string]any) (any, error) { + patterns, err := optList(args, "patterns") + if err != nil { + return nil, err + } + return Match(patterns) + }, + }, + { + Name: "fonts_glyph", + Description: "Which installed fonts have a given character, and which face monospace and sans-serif would draw " + + "it with. The character is given as itself or as a code point (U+F120, 0xF120). (r)", + Input: map[string]any{ + "character": map[string]any{"type": "string", "description": "one character, or its code point as U+XXXX or 0xXXXX"}, + }, + Run: func(args map[string]any) (any, error) { + ch, err := text(args, "character") + if err != nil { + return nil, err + } + return Glyph(ch) + }, + }, + { + Name: "fonts_sources", + Description: "Every font file copied into the account's font directories by hand, with its family, whether it " + + "duplicates another, and whether a package now provides that family; and the families installed by " + + "packages, with the package. Says which hand-copied files can be removed and why; removes nothing. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Sources() }, + }, + { + Name: "fonts_config", + Description: "The fontconfig file the fonts module owns: whether it is in place, whether fontconfig loads it, " + + "and the account's other fontconfig files beside it. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Config() }, + }, + { + Name: "fonts_cache_rebuild", + Description: "Rebuild the font cache: the account's (default), or the system's with system: true, which needs " + + "root and goes through sudo without a prompt. Answers how long it took. (a)", + Input: map[string]any{ + "system": map[string]any{"type": "boolean", "description": "rebuild the system cache instead of the account's"}, + }, + Run: func(args map[string]any) (any, error) { + system, err := optFlag(args, "system", false) + if err != nil { + return nil, err + } + return CacheRebuild(system) + }, + }, + } +} diff --git a/modules/fonts/cmd/fonts-tools/manifest_kit_test.go b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go @@ -0,0 +1,107 @@ +package main + +// manifest_kit_test.go is the same file in each workstation module: it reads the module's +// definition so the module's own tests can hold it to what it says. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + return nil +} + +// packages are the packages the module installs, sorted. +func (m manifest) packages() []string { + out := []string{} + for _, r := range m.Resources { + if r["type"] == "package" && r["absent"] != true { + out = append(out, r["package"].(string)) + } + } + sort.Strings(out) + return out +} + +// services are the units the module declares, by unit name. +func (m manifest) services() map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if r["type"] == "service" { + out[r["unit"].(string)] = r + } + } + return out +} + +// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered +// is listed and nothing else, each named _…, and the artifact builds this command. +func holdsTheBundle(t *testing.T, m manifest, prefix string) { + t.Helper() + registered := []string{} + for _, tool := range tools() { + registered = append(registered, tool.Name) + if !strings.HasPrefix(tool.Name, prefix+"_") { + t.Errorf("tool %s is not named %s_…", tool.Name, prefix) + } + if tool.Description == "" || tool.Run == nil || tool.Input == nil { + t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name) + } + } + if strings.Join(registered, ",") != strings.Join(m.Tools, ",") { + t.Errorf("registered %v, listed %v", registered, m.Tools) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("one artifact, got %d", len(m.Build.Artifacts)) + } + cwd, _ := os.Getwd() + binary := filepath.Base(cwd) + a := m.Build.Artifacts[0] + want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary} + for k, v := range want { + if a[k] != v { + t.Errorf("artifact %s = %v, want %v", k, a[k], v) + } + } + if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary { + t.Errorf("artifact loads %v, want [%s]", a["loads"], binary) + } + if m.Claims != nil || m.Seats != nil { + t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats) + } +} diff --git a/modules/fonts/go.mod b/modules/fonts/go.mod new file mode 100644 index 0000000..9341c24 --- /dev/null +++ b/modules/fonts/go.mod @@ -0,0 +1,5 @@ +module fonts + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/fonts/go.sum b/modules/fonts/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/fonts/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/fonts/module.json b/modules/fonts/module.json new file mode 100644 index 0000000..e668440 --- /dev/null +++ b/modules/fonts/module.json @@ -0,0 +1,65 @@ +{ + "module": "fonts", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "fonts_families", + "fonts_match", + "fonts_glyph", + "fonts_sources", + "fonts_config", + "fonts_cache_rebuild" + ], + "resources": [ + { + "id": "monospace", + "type": "package", + "package": "ttf-jetbrains-mono-nerd" + }, + { + "id": "interface", + "type": "package", + "package": "inter-font" + }, + { + "id": "symbols", + "type": "package", + "package": "ttf-nerd-fonts-symbols" + }, + { + "id": "noto", + "type": "package", + "package": "noto-fonts" + }, + { + "id": "emoji", + "type": "package", + "package": "noto-fonts-emoji" + }, + { + "id": "defaults", + "type": "file", + "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "\n\n\n\n The mesh: the faces monospace, sans-serif, serif and emoji mean\n\n \n Hack Nerd Fontmonospace\n MesloLGS NFmonospace\n Iosevka Nerd Fontmonospace\n JetBrains Mono Nerd FontJetBrainsMono Nerd Font\n\n \n monospaceJetBrainsMono Nerd Font\n sans-serifInterNoto Sans\n system-uiInter\n serifNoto Serif\n emojiNoto Color Emoji\n\n \n \n Symbols Nerd Font\n Noto Color Emoji\n \n\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/fonts-tools", + "binary": "fonts-tools", + "loads": [ + "fonts-tools" + ] + } + ] + } +}