diff --git a/modules/bluetooth/README.md b/modules/bluetooth/README.md new file mode 100644 index 0000000..08c6a71 --- /dev/null +++ b/modules/bluetooth/README.md @@ -0,0 +1,53 @@ +# bluetooth + +Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9). + +## Owns + +| what | where | +|---|---| +| the Bluetooth stack and its daemon | package `bluez` | +| `bluetoothctl`, which the tools speak through | package `bluez-utils` | +| the daemon, running and enabled | `bluetooth.service` | + +All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations +(every setting commented out). The module states nothing in it, so it declares nothing there. + +## Improves + +- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is + installed only as a dependency. Removing the applet that pulled it in would have left it an orphan + for the next clean-up to take, and Bluetooth with it. +- **An owner for the daemon**, running and enabled on both today with nothing recording why. +- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from + any machine, without the applet. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets +the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act +whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever +bluetoothctl's exit status. + +| tool | what | +|---|---| +| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering | +| `bluetooth_power` (r/a) | read, or switch the controller on or off | +| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported | +| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first | +| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s | +| `bluetooth_trust` (a) | trust, or untrust | +| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop | +| `bluetooth_remove` (a) | forget a device | + +## What changes when it is assigned + +Nothing on disk on either workstation: both packages are installed, and the service is enabled and +running. `bluez` becomes explicitly the mesh's. + +## Leaves as found + +- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state). +- `blueman` on both workstations, and its applet, which the window manager's configuration starts. + That line is the `i3` module's to keep or drop. +- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop. diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go new file mode 100644 index 0000000..3022882 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go @@ -0,0 +1,281 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`) + +func addressOf(args map[string]any) (string, error) { + a, err := text(args, "address") + if err != nil { + return "", err + } + if !macAddress.MatchString(a) { + return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a) + } + return strings.ToUpper(a), nil +} + +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`) + +// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status. +var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`) + +// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the +// account act, and if it refuses, the act is run through sudo -n. +func bt(timeout time.Duration, args ...string) (string, error) { + c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout} + r := run(c) + if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") { + c.Root = true + r = run(c) + } + out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "") + if r.Error != "" { + return out, failure(c, r) + } + if strings.Contains(out, "No default controller available") { + return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running") + } + if m := btFailed.FindString(out); m != "" || r.Status != 0 { + said := strings.TrimSpace(out) + if said == "" { + said = fmt.Sprintf("exit status %d", r.Status) + } + return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000)) + } + return out, nil +} + +// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value. +func fields(s string) map[string]string { + out := map[string]string{} + for _, l := range strings.Split(s, "\n") { + if !strings.HasPrefix(l, "\t") { + continue + } + k, v, ok := strings.Cut(strings.TrimSpace(l), ":") + if !ok { + continue + } + if _, seen := out[k]; !seen { + out[k] = strings.TrimSpace(v) + } + } + return out +} + +func yes(v string) bool { return v == "yes" } + +// ControllerAnswer is what bluetooth_controller answers. +type ControllerAnswer struct { + Address string `json:"address"` + Name string `json:"name"` + Alias string `json:"alias"` + Powered bool `json:"powered"` + PowerState string `json:"power_state,omitempty"` + Discoverable bool `json:"discoverable"` + Pairable bool `json:"pairable"` + Discovering bool `json:"discovering"` +} + +// Controller answers the default controller. +func Controller() (ControllerAnswer, error) { + out, err := bt(CallTimeout, "show") + if err != nil { + return ControllerAnswer{}, err + } + c := ControllerAnswer{} + for _, l := range strings.Split(out, "\n") { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" { + c.Address = f[1] + break + } + } + if c.Address == "" { + return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500)) + } + f := fields(out) + c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"] + c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"]) + return c, nil +} + +// Power switches the controller on or off. +func Power(on bool) (map[string]any, error) { + word := "off" + if on { + word = "on" + } + if _, err := bt(CallTimeout, "power", word); err != nil { + return nil, err + } + c, err := Controller() + if err != nil { + return nil, err + } + return map[string]any{"powered": c.Powered, "asked": word}, nil +} + +// Device is one device bluez knows. +type Device struct { + Address string `json:"address"` + Name string `json:"name"` + Icon string `json:"kind,omitempty"` + Paired bool `json:"paired"` + Bonded bool `json:"bonded"` + Trusted bool `json:"trusted"` + Blocked bool `json:"blocked"` + Connected bool `json:"connected"` + Battery *int `json:"battery_percent,omitempty"` + RSSI *int `json:"rssi,omitempty"` +} + +var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`) + +// number reads "0x50 (80)" or "-62" as a number. +func number(v string) *int { + if m := inParens.FindStringSubmatch(v); m != nil { + v = m[1] + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return nil + } + return &n +} + +// deviceInfo asks bluez for one device. +func deviceInfo(address string) (Device, error) { + out, err := bt(CallTimeout, "info", address) + if err != nil { + return Device{}, err + } + f := fields(out) + d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]), + Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])} + if d.Name == "" { + d.Name = f["Alias"] + } + if v, ok := f["Battery Percentage"]; ok { + d.Battery = number(v) + } + if v, ok := f["RSSI"]; ok { + d.RSSI = number(v) + } + return d, nil +} + +// listed reads "Device
" lines. +func listed(out string) []string { + seen := map[string]bool{} + addrs := []string{} + for _, l := range strings.Split(out, "\n") { + f := strings.Fields(strings.TrimSpace(l)) + if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] { + seen[f[1]] = true + addrs = append(addrs, f[1]) + } + } + return addrs +} + +// Devices answers the devices bluez knows, with each one's state. +func Devices(which string) (map[string]any, error) { + if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil { + return nil, err + } + args := []string{"devices"} + if which != "all" { + args = append(args, strings.ToUpper(which[:1])+which[1:]) + } + out, err := bt(CallTimeout, args...) + if err != nil { + return nil, err + } + devices := []Device{} + for _, a := range listed(out) { + d, err := deviceInfo(a) + if err != nil { + return nil, err + } + devices = append(devices, d) + } + sort.SliceStable(devices, func(i, k int) bool { + if devices[i].Connected != devices[k].Connected { + return devices[i].Connected + } + return devices[i].Name < devices[k].Name + }) + return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil +} + +// Scan discovers for a while and answers the devices found that are not paired. +func Scan(seconds int) (map[string]any, error) { + // bluetoothctl's own --timeout ends the scan; the command's bound is a little longer. + limit := time.Duration(seconds+4) * time.Second + if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil { + return nil, err + } + out, err := bt(CallTimeout, "devices") + if err != nil { + return nil, err + } + found := []Device{} + for _, a := range listed(out) { + // A device seen a moment ago may have gone out of reach: it is skipped, not a failure. + d, err := deviceInfo(a) + if err != nil { + continue + } + if !d.Paired { + found = append(found, d) + } + } + sort.SliceStable(found, func(i, k int) bool { + ri, rk := -1000, -1000 + if found[i].RSSI != nil { + ri = *found[i].RSSI + } + if found[k].RSSI != nil { + rk = *found[k].RSSI + } + return ri > rk + }) + return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil +} + +// Act runs one act on a device and answers the device's state afterwards. +func Act(verb, address string) (map[string]any, error) { + limit := CallTimeout + if verb == "connect" { + // A connect waits for the device; bluetoothctl's own timeout ends it first. + if _, err := bt(limit, "--timeout", "15", verb, address); err != nil { + return nil, err + } + } else if _, err := bt(limit, verb, address); err != nil { + return nil, err + } + if verb == "remove" { + return map[string]any{"act": verb, "address": address, "removed": true}, nil + } + d, err := deviceInfo(address) + if err != nil { + return nil, err + } + return map[string]any{"act": verb, "device": d}, nil +} + +// Pair pairs a device with an agent that confirms nothing, then trusts it. +func Pair(address string) (map[string]any, error) { + if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil { + return nil, err + } + return Act("trust", address) +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go new file mode 100644 index 0000000..117c5e5 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "bluetooth") + if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" { + t.Errorf("packages %s: the applet and the TUI are the operator's", got) + } + s := m.services()["bluetooth.service"] + if s == nil || s["state"] != "running" || s["boot"] != "enabled" { + t.Errorf("%v", s) + } + if len(m.Resources) != 3 { + t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources) + } +} + +const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n" + +func headphones(connected bool) string { + c := "no" + extra := "" + if connected { + c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n" + } + return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n" +} + +func TestControllerReadsShow(t *testing.T) { + using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) }) + c, err := Controller() + if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable { + t.Fatalf("%+v %v", c, err) + } + using(t, func(string, Cmd) Result { return ok("No default controller available\n") }) + if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") { + t.Fatalf("%v", err) + } +} + +func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + switch line { + case "bluetoothctl devices Paired": + return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n") + case "bluetoothctl info 80:99:E7:C2:29:DA": + return ok(headphones(true)) + } + return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n") + }) + got, err := Devices("paired") + devices := got["devices"].([]Device) + if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "bluetoothctl devices Paired" { + t.Errorf("%v", f.lines()) + } + if _, err := Devices("nearby"); err == nil { + t.Error("an unknown which") + } +} + +func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) { + using(t, func(line string, c Cmd) Result { + return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n") + }) + if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") { + t.Fatalf("%v", err) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} }) + if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") { + t.Fatalf("%v", err) + } +} + +func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "connect") { + return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n") + } + return ok(headphones(true)) + }) + got, err := Act("connect", "80:99:E7:C2:29:DA") + if err != nil || !got["device"].(Device).Connected { + t.Fatalf("%v %v", got, err) + } + if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout { + t.Errorf("%v", f.lines()) + } +} + +func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.HasPrefix(line, "sudo") { + return ok("Changing power off succeeded\n" + show) + } + return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n") + }) + if _, err := Power(false); err != nil { + t.Fatal(err) + } + if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" { + t.Errorf("%v", l) + } +} + +func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + switch { + case strings.Contains(line, "scan on"): + return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n") + case line == "bluetoothctl devices": + return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n") + case strings.HasSuffix(line, "EE:01"): + return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n") + case strings.HasSuffix(line, "EE:02"): + return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n") + case strings.HasSuffix(line, "EE:03"): + return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"} + } + return ok(headphones(false)) + }) + got, err := Scan(8) + found := got["found"].([]Device) + if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 { + t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout) + } +} + +func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, " pair ") { + return ok("Pairing successful\n") + } + return ok(headphones(false)) + }) + if _, err := Pair("80:99:e7:c2:29:da"); err != nil { + t.Fatal(err) + } + if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" { + t.Errorf("%v", l) + } +} + +func TestAnAddressIsSixHexPairs(t *testing.T) { + for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} { + if _, err := addressOf(map[string]any{"address": bad}); err == nil { + t.Errorf("%q accepted", bad) + } + } + if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" { + t.Errorf("%s %v", a, err) + } +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit.go b/modules/bluetooth/cmd/bluetooth-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-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/bluetooth/cmd/bluetooth-tools/kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-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/bluetooth/cmd/bluetooth-tools/main.go b/modules/bluetooth/cmd/bluetooth-tools/main.go new file mode 100644 index 0000000..f063aaf --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/main.go @@ -0,0 +1,135 @@ +// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with +// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator +// account, and speaks to bluez through bluetoothctl. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "bluetoothctl": "the bluez-utils package, which this module installs", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"} + +func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + a, err := addressOf(args) + if err != nil { + return nil, err + } + return f(a) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "bluetooth_controller", + Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Controller() }, + }, + { + Name: "bluetooth_power", + Description: "Whether the controller is powered; with on, switch it on or off. (r/a)", + Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}}, + Run: func(args map[string]any) (any, error) { + if _, given := args["on"]; !given { + c, err := Controller() + if err != nil { + return nil, err + } + return map[string]any{"powered": c.Powered}, nil + } + on, err := optFlag(args, "on", true) + if err != nil { + return nil, err + } + return Power(on) + }, + }, + { + Name: "bluetooth_devices", + Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " + + "name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)", + Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}}, + Run: func(args map[string]any) (any, error) { + which, err := optText(args, "which", "all") + if err != nil { + return nil, err + } + return Devices(which) + }, + }, + { + Name: "bluetooth_scan", + Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " + + "paired, with their signal strength. (r)", + Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}}, + Run: func(args map[string]any) (any, error) { + s, err := optWhole(args, "seconds", 8, 1, 15) + if err != nil { + return nil, err + } + return Scan(s) + }, + }, + { + Name: "bluetooth_connect", + Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("connect", a) }), + }, + { + Name: "bluetooth_disconnect", + Description: "Disconnect a device. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }), + }, + { + Name: "bluetooth_trust", + Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)", + Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}}, + Run: func(args map[string]any) (any, error) { + a, err := addressOf(args) + if err != nil { + return nil, err + } + trusted, err := optFlag(args, "trusted", true) + if err != nil { + return nil, err + } + if trusted { + return Act("trust", a) + } + return Act("untrust", a) + }, + }, + { + Name: "bluetooth_pair", + Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " + + "confirmed, such as headphones; one that shows a code is paired from the desktop. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Pair(a) }), + }, + { + Name: "bluetooth_remove", + Description: "Forget a device: unpair it and drop what bluez knows of it. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("remove", a) }), + }, + } +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-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/bluetooth/go.mod b/modules/bluetooth/go.mod new file mode 100644 index 0000000..790596e --- /dev/null +++ b/modules/bluetooth/go.mod @@ -0,0 +1,5 @@ +module bluetooth + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/bluetooth/go.sum b/modules/bluetooth/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/bluetooth/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/bluetooth/module.json b/modules/bluetooth/module.json new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/modules/bluetooth/module.json @@ -0,0 +1,53 @@ +{ + "module": "bluetooth", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "bluetooth_controller", + "bluetooth_power", + "bluetooth_devices", + "bluetooth_scan", + "bluetooth_connect", + "bluetooth_disconnect", + "bluetooth_trust", + "bluetooth_pair", + "bluetooth_remove" + ], + "resources": [ + { + "id": "stack", + "type": "package", + "package": "bluez" + }, + { + "id": "utilities", + "type": "package", + "package": "bluez-utils" + }, + { + "id": "daemon", + "type": "service", + "unit": "bluetooth.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/bluetooth-tools", + "binary": "bluetooth-tools", + "loads": [ + "bluetooth-tools" + ] + } + ] + } +} diff --git a/modules/cups/README.md b/modules/cups/README.md new file mode 100644 index 0000000..6141bbd --- /dev/null +++ b/modules/cups/README.md @@ -0,0 +1,84 @@ +# cups + +Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42 +phase 2 step 9). + +## Owns + +| what | where | +|---|---| +| the print scheduler | package `cups` | +| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` | +| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled | + +All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates) +and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and +the module declares none of them. + +## The printer's driver: none needed for the Brother + +Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04: + +| workstation | queue | device | prints through | driver package installed | +|---|---|---|---|---| +| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused | +| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused | +| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) | + +**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support, +with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue, +and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares +no driver, and the Brother needs nothing outside the official repositories. + +**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012, +with its own network backend. Like snapd, it waits for the mesh's package repository (research 027 +question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check +with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces +it and the question goes away. + +## Improves + +- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records. +- **No driver package that nothing uses.** The README's one-off step below removes them, once. +- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a + low one. It also answers why a queue stopped, without opening the printer's page. +- **The laptop has no default printer**, so a print without a named printer fails there. + `cups_default` sets one; it is the operator's choice, not declared. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account +print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another +account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the +account first. + +| tool | what | +|---|---| +| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) | +| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded | +| `cups_cancel` (a) | one job, or every job on a printer | +| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id | +| `cups_default` (r/a) | read the default, or set it | +| `cups_resume` (a) | enable a stopped printer and make it accept jobs | +| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered | + +## What changes when it is assigned + +Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its +dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the +mesh's; `cups-filters` is now declared explicitly. + +## The one-off step for the operator (ADR 0182) + +The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do +today), remove the unused vendor drivers, once: + +- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in + (`lib32-glibc`). +- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while + the Canon queue is used. + +## Leaves as found + +The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset), +`system-config-printer` on the desktop, and `cnijfilter-mg4200`. diff --git a/modules/cups/cmd/cups-tools/cups.go b/modules/cups/cmd/cups-tools/cups.go new file mode 100644 index 0000000..659516d --- /dev/null +++ b/modules/cups/cmd/cups-tools/cups.go @@ -0,0 +1,560 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`) + +func checkPrinter(p string) error { + if !queueName.MatchString(p) || strings.HasPrefix(p, "-") { + return fmt.Errorf("%q is not a printer queue's name", p) + } + return nil +} + +var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) +var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`) + +// optionsOf reads the print options object. +func optionsOf(args map[string]any) (map[string]string, error) { + v, ok := args["options"] + if !ok || v == nil { + return nil, nil + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("options must be an object of names to values") + } + out := map[string]string{} + for k, val := range m { + s, ok := val.(string) + if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) { + return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val) + } + out[k] = s + } + return out, nil +} + +// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes. +func lpoptionsOf(s string) map[string]string { + out := map[string]string{} + s = strings.TrimSpace(s) + i := 0 + for i < len(s) { + for i < len(s) && s[i] == ' ' { + i++ + } + start := i + for i < len(s) && s[i] != '=' && s[i] != ' ' { + i++ + } + key := s[start:i] + if i >= len(s) || s[i] != '=' { + if key != "" { + out[key] = "" + } + continue + } + i++ + var b strings.Builder + for i < len(s) && s[i] != ' ' { + switch s[i] { + case '\'': + i++ + for i < len(s) && s[i] != '\'' { + if s[i] == '\\' && i+1 < len(s) { + i++ + } + b.WriteByte(s[i]) + i++ + } + i++ + case '\\': + if i+1 < len(s) { + b.WriteByte(s[i+1]) + } + i += 2 + default: + b.WriteByte(s[i]) + i++ + } + } + out[key] = b.String() + } + return out +} + +// Marker is one supply the printer reports. +type Marker struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Level int `json:"level_percent"` + Low bool `json:"low"` +} + +// Printer is one queue. +type Printer struct { + Name string `json:"name"` + State string `json:"state"` + Enabled bool `json:"enabled"` + Accepting bool `json:"accepting"` + Default bool `json:"default"` + URI string `json:"uri"` + MakeModel string `json:"make_and_model"` + Driverless bool `json:"driverless"` + Shared bool `json:"shared"` + Reasons []string `json:"reasons"` + Markers []Marker `json:"markers"` + Since string `json:"since,omitempty"` + Message string `json:"message,omitempty"` +} + +// PrintersAnswer is what cups_printers answers. +type PrintersAnswer struct { + Scheduler string `json:"scheduler"` + Default string `json:"default,omitempty"` + Printers []Printer `json:"printers"` +} + +func lpstat(args ...string) (string, error) { + r, err := call(Cmd{Name: "lpstat", Args: args}) + if err != nil { + if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") { + return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr)) + } + // lpstat answers "No destinations added." with a non-zero status: that is no printers. + if strings.Contains(r.Stderr, "No destinations added") { + return "", nil + } + return "", err + } + return r.Stdout, nil +} + +func defaultPrinter() (string, error) { + out, err := lpstat("-d") + if err != nil { + return "", err + } + if _, after, ok := strings.Cut(out, "system default destination: "); ok { + return strings.TrimSpace(firstLine(after)), nil + } + return "", nil +} + +// Printers answers every queue with its state, device, model and supplies. +func Printers() (PrintersAnswer, error) { + out := PrintersAnswer{Printers: []Printer{}} + sched, err := lpstat("-r") + if err != nil { + return out, err + } + out.Scheduler = strings.TrimSpace(sched) + if out.Default, err = defaultPrinter(); err != nil { + return out, err + } + ps, err := lpstat("-p") + if err != nil { + return out, err + } + var cur *Printer + for _, l := range strings.Split(ps, "\n") { + if strings.HasPrefix(l, "printer ") { + f := strings.Fields(l) + if len(f) < 3 { + continue + } + out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}}) + cur = &out.Printers[len(out.Printers)-1] + rest := strings.Join(f[2:], " ") + switch { + case strings.HasPrefix(rest, "is idle"): + cur.State = "idle" + case strings.HasPrefix(rest, "now printing"): + cur.State = "printing" + default: + cur.State = "stopped" + } + // "disabled since …" (stopped), or "enabled since …" after the state. + cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since") + if _, since, found := strings.Cut(rest, " since "); found { + cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -") + } + continue + } + if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" { + cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l)) + } + } + acc, err := lpstat("-a") + if err != nil { + return out, err + } + accepting := map[string]bool{} + for _, l := range lines(acc) { + if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" { + accepting[f[0]] = true + } + } + for i := range out.Printers { + p := &out.Printers[i] + p.Accepting = accepting[p.Name] + p.Default = p.Name == out.Default + r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}}) + if err != nil { + return out, err + } + o := lpoptionsOf(r.Stdout) + p.URI = o["device-uri"] + p.MakeModel = o["printer-make-and-model"] + p.Driverless = driverless(p.MakeModel, p.URI) + p.Shared = o["printer-is-shared"] == "true" + for _, reason := range strings.Split(o["printer-state-reasons"], ",") { + if reason = strings.TrimSpace(reason); reason != "" && reason != "none" { + p.Reasons = append(p.Reasons, reason) + } + } + p.Markers = markersOf(o) + } + return out, nil +} + +// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a +// driverless URI. +func driverless(model, uri string) bool { + m := strings.ToLower(model) + return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") || + strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere") +} + +func markersOf(o map[string]string) []Marker { + split := func(k string) []string { + if o[k] == "" { + return nil + } + return strings.Split(o[k], ",") + } + names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types") + out := []Marker{} + for i, n := range names { + if i >= len(levels) { + break + } + level, err := strconv.Atoi(strings.TrimSpace(levels[i])) + if err != nil { + continue + } + m := Marker{Name: strings.TrimSpace(n), Level: level} + if i < len(types) { + m.Type = strings.TrimSpace(types[i]) + } + if i < len(lows) { + if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low { + m.Low = true + } + } + out = append(out, m) + } + return out +} + +// Job is one print job. +type Job struct { + ID string `json:"id"` + Printer string `json:"printer"` + User string `json:"user"` + Bytes int64 `json:"bytes"` + Submitted string `json:"submitted"` +} + +// Queue answers the jobs waiting, or the finished ones. +func Queue(printer string, completed bool, limit int) (map[string]any, error) { + args := []string{} + if completed { + args = append(args, "-W", "completed") + } + args = append(args, "-o") + if printer != "" { + if err := checkPrinter(printer); err != nil { + return nil, err + } + args = append(args, printer) + } + out, err := lpstat(args...) + if err != nil { + return nil, err + } + jobs := []Job{} + for _, l := range lines(out) { + f := strings.Fields(l) + if len(f) < 4 { + continue + } + i := strings.LastIndex(f[0], "-") + if i <= 0 { + continue + } + size, _ := strconv.ParseInt(f[2], 10, 64) + jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")}) + } + sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) }) + total := len(jobs) + if len(jobs) > limit { + jobs = jobs[:limit] + } + return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil +} + +func jobNumber(id string) int { + n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:]) + return n +} + +var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`) + +// refused says CUPS kept an act for its administrators. +func refused(r Result) bool { + s := r.Stderr + r.Stdout + return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed") +} + +// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account. +func asAccountThenRoot(c Cmd) (Result, bool, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, false, nil + } + if !refused(r) { + return r, false, failure(c, r) + } + c.Root = true + r, err := call(c) + return r, true, err +} + +// Cancel cancels one job, or every job on a printer. +func Cancel(job, printer string, all bool) (map[string]any, error) { + var c Cmd + switch { + case all: + if printer == "" { + return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered") + } + if err := checkPrinter(printer); err != nil { + return nil, err + } + c = Cmd{Name: "cancel", Args: []string{"-a", printer}} + case job != "": + if !jobID.MatchString(job) { + return nil, fmt.Errorf("%q is not a job id", job) + } + c = Cmd{Name: "cancel", Args: []string{job}} + default: + return nil, fmt.Errorf("give job, or printer with all") + } + _, escalated, err := asAccountThenRoot(c) + if err != nil { + return nil, err + } + return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil +} + +var requestID = regexp.MustCompile(`request id is (\S+)`) + +// statFile tells a regular file. Tests replace it. +var statFile = func(p string) error { + info, err := os.Stat(p) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", p) + } + return nil +} + +// Print sends a file to a printer. +func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) { + if !filepath.IsAbs(file) { + return nil, fmt.Errorf("file must be an absolute path, not %q", file) + } + if err := statFile(file); err != nil { + return nil, fmt.Errorf("cannot print %s: %v", file, err) + } + args := []string{} + if printer != "" { + if err := checkPrinter(printer); err != nil { + return nil, err + } + args = append(args, "-d", printer) + } + args = append(args, "-n", strconv.Itoa(copies)) + names := make([]string, 0, len(opts)) + for k := range opts { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + args = append(args, "-o", k+"="+opts[k]) + } + if title == "" { + title = filepath.Base(file) + } + args = append(args, "-t", title, "--", file) + r, err := call(Cmd{Name: "lp", Args: args}) + if err != nil { + if strings.Contains(r.Stderr, "No default destination") { + return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default") + } + return nil, err + } + m := requestID.FindStringSubmatch(r.Stdout) + if m == nil { + return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr)) + } + return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil +} + +// Default answers the default printer, or sets it. +func Default(printer string) (map[string]any, error) { + if printer == "" { + d, err := defaultPrinter() + if err != nil { + return nil, err + } + return map[string]any{"default": d}, nil + } + if err := checkPrinter(printer); err != nil { + return nil, err + } + was, err := defaultPrinter() + if err != nil { + return nil, err + } + if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil { + return nil, err + } + return map[string]any{"default": printer, "was": was}, nil +} + +// Resume enables a printer and makes it accept jobs. +func Resume(printer string) (map[string]any, error) { + if err := checkPrinter(printer); err != nil { + return nil, err + } + for _, c := range []string{"cupsenable", "cupsaccept"} { + if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil { + return nil, err + } + } + return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil +} + +// DriverPackage is a package that brings filters or backends. +type DriverPackage struct { + Package string `json:"package"` + Version string `json:"version"` + Foreign bool `json:"foreign"` +} + +// Model is one driver model CUPS offers. +type Model struct { + PPD string `json:"ppd"` + Description string `json:"description"` +} + +// QueueDriver is how one queue prints. +type QueueDriver struct { + Printer string `json:"printer"` + MakeModel string `json:"make_and_model"` + Driverless bool `json:"driverless"` + URI string `json:"uri"` +} + +// DriversAnswer is what cups_drivers answers. +type DriversAnswer struct { + Queues []QueueDriver `json:"queues"` + Packages []DriverPackage `json:"driver_packages"` + Models []Model `json:"models"` + Matched int `json:"models_matched"` + Findings []string `json:"findings"` +} + +// driverDirs are where drivers put what CUPS runs. +var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"} + +// basePackages bring CUPS's own filters and backends, not a printer's driver. +var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false} + +// Drivers answers how each queue prints and which packages bring drivers. +func Drivers(match string, limit int) (DriversAnswer, error) { + out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}} + ps, err := Printers() + if err != nil { + return out, err + } + for _, p := range ps.Printers { + out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI}) + } + r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)}) + if r.Error != "" { + return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r) + } + seen := map[string]bool{} + for _, l := range lines(r.Stdout) { + if _, after, ok := strings.Cut(l, " is owned by "); ok { + f := strings.Fields(after) + if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] { + seen[f[0]] = true + out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]}) + } + } + } + if len(out.Packages) > 0 { + r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}}) + foreign := map[string]bool{} + for _, l := range lines(r.Stdout) { + foreign[strings.TrimSpace(l)] = true + } + for i := range out.Packages { + out.Packages[i].Foreign = foreign[out.Packages[i].Package] + } + } + sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package }) + m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}}) + if err != nil { + return out, err + } + for _, l := range lines(m.Stdout) { + ppd, desc, _ := strings.Cut(l, " ") + if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) { + continue + } + out.Matched++ + if len(out.Models) < limit { + out.Models = append(out.Models, Model{PPD: ppd, Description: desc}) + } + } + // Which driver packages no queue prints through. + allDriverless := len(out.Queues) > 0 + for _, q := range out.Queues { + allDriverless = allDriverless && q.Driverless + } + for _, p := range out.Packages { + if p.Foreign { + out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories") + } + if allDriverless { + out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package) + } + } + return out, nil +} diff --git a/modules/cups/cmd/cups-tools/cups_test.go b/modules/cups/cmd/cups-tools/cups_test.go new file mode 100644 index 0000000..d58561a --- /dev/null +++ b/modules/cups/cmd/cups-tools/cups_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "cups") + if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" { + t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got) + } + s := m.services() + for _, u := range []string{"cups.socket", "cups.service"} { + if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" { + t.Errorf("%s: %v", u, s[u]) + } + } + for _, r := range m.Resources { + if r["type"] == "file" { + t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"]) + } + } +} + +// The desktop's two queues on 2026-10-04. +func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake { + return using(t, func(line string, c Cmd) Result { + if more != nil { + if r, handled := more(line, c); handled { + return r + } + } + switch line { + case "lpstat -r": + return ok("scheduler is running\n") + case "lpstat -d": + return ok("system default destination: Brother_MFC_Novox\n") + case "lpstat -p": + return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" + + "printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n") + case "lpstat -a": + return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n") + case "lpoptions -p Brother_MFC_Novox": + return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`) + case "lpoptions -p Kanjuro": + return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`) + } + return Result{Status: 9, Stderr: "unexpected " + line} + }) +} + +func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) { + theDesktop(t, nil) + got, err := Printers() + if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" { + t.Fatalf("%+v %v", got, err) + } + b, k := got.Printers[0], got.Printers[1] + if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 { + t.Errorf("%+v", b) + } + if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low { + t.Errorf("markers %+v", b.Markers) + } + if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" { + t.Errorf("%+v", k) + } +} + +func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if line == "lpstat -r" { + return ok("scheduler is running\n") + } + return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"} + }) + got, err := Printers() + if err != nil || len(got.Printers) != 0 { + t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} }) + if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") { + t.Fatalf("%v", err) + } +} + +func TestLpoptionsQuotingIsRead(t *testing.T) { + o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`) + if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" { + t.Errorf("%v", o) + } +} + +func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) { + f := using(t, func(string, Cmd) Result { + return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n") + }) + got, err := Queue("Brother", true, 2) + jobs := got["jobs"].([]Job) + if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "lpstat -W completed -o Brother" { + t.Errorf("%v", f.lines()) + } + if _, err := Queue("-h", false, 5); err == nil { + t.Error("an option as a printer") + } +} + +func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if !strings.HasPrefix(line, "sudo") { + return Result{Status: 1, Stderr: "cancel: Forbidden\n"} + } + return ok("") + }) + got, err := Cancel("Brother-12", "", false) + if err != nil || got["as_root"] != true { + t.Fatalf("%v %v", got, err) + } + if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" { + t.Errorf("%v", f.lines()) + } + f = using(t, func(string, Cmd) Result { return ok("") }) + if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" { + t.Fatalf("%v %v %v", got, err, f.lines()) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} }) + if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") { + t.Errorf("a failure that is not a refusal is not retried as root: %v", err) + } + for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} { + if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) { + was := statFile + defer func() { statFile = was }() + statFile = func(p string) error { + if p == "/home/op/doc.pdf" { + return nil + } + return errString("no such file") + } + f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") }) + got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "") + if err != nil || got["job"] != "Brother-13" { + t.Fatalf("%v %v", got, err) + } + if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" { + t.Errorf("%s", l) + } + if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil { + t.Error("a relative file") + } + if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil { + t.Error("a missing file") + } + if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil { + t.Error("an option value with a space") + } + if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil { + t.Error("an option name that is an option") + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} }) + if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") { + t.Errorf("%v", err) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func TestDefaultReadsAndSetsThroughSudo(t *testing.T) { + f := theDesktop(t, func(line string, c Cmd) (Result, bool) { + if strings.HasPrefix(line, "sudo -n lpadmin") { + return ok(""), true + } + return Result{}, false + }) + got, err := Default("") + if err != nil || got["default"] != "Brother_MFC_Novox" { + t.Fatalf("%v %v", got, err) + } + got, err = Default("Kanjuro") + if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" { + t.Fatalf("%v %v", got, err) + } + if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" { + t.Errorf("%v", l) + } +} + +func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok("") }) + if _, err := Resume("Kanjuro"); err != nil { + t.Fatal(err) + } + if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" { + t.Errorf("%v", f.lines()) + } +} + +func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) { + theDesktop(t, func(line string, c Cmd) (Result, bool) { + switch { + case strings.HasPrefix(line, "pacman -Qo"): + return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true + case line == "pacman -Qqm": + return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true + case line == "lpinfo -m": + return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true + } + return Result{}, false + }) + got, err := Drivers("canon", 10) + if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" { + t.Fatalf("%+v %v", got, err) + } + if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" { + t.Errorf("%+v", got.Packages) + } + all := strings.Join(got.Findings, ";") + if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") { + t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all) + } +} diff --git a/modules/cups/cmd/cups-tools/kit.go b/modules/cups/cmd/cups-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/cups/cmd/cups-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/cups/cmd/cups-tools/kit_test.go b/modules/cups/cmd/cups-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/cups/cmd/cups-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/cups/cmd/cups-tools/main.go b/modules/cups/cmd/cups-tools/main.go new file mode 100644 index 0000000..93cd848 --- /dev/null +++ b/modules/cups/cmd/cups-tools/main.go @@ -0,0 +1,177 @@ +// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and +// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime +// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for +// its administrators goes through `sudo -n`. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "lpstat": "the cups package, which this module installs", + "lpoptions": "the cups package, which this module installs", + "lp": "the cups package, which this module installs", + "cancel": "the cups package, which this module installs", + "lpadmin": "the cups package, which this module installs", + "lpinfo": "the cups package, which this module installs", + "cupsenable": "the cups package, which this module installs", + "cupsaccept": "the cups package, which this module installs", + "pacman": "this is not an Arch machine", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "cups_printers", + Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " + + "address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " + + "supply levels where the printer reports them. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Printers() }, + }, + { + Name: "cups_queue", + Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " + + "Each with its id, printer, owner, size and when it was submitted. (r)", + Input: map[string]any{ + "printer": printerArg, + "completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"}, + "limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"}, + }, + Run: func(args map[string]any) (any, error) { + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + done, err := optFlag(args, "completed", false) + if err != nil { + return nil, err + } + limit, err := optWhole(args, "limit", 50, 1, 500) + if err != nil { + return nil, err + } + return Queue(p, done, limit) + }, + }, + { + Name: "cups_cancel", + Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " + + "account's job is cancelled through sudo -n. (a)", + Input: map[string]any{ + "job": map[string]any{"type": "string", "description": "the job id"}, + "printer": printerArg, + "all": map[string]any{"type": "boolean", "description": "every job on printer"}, + }, + Run: func(args map[string]any) (any, error) { + job, err := optText(args, "job", "") + if err != nil { + return nil, err + } + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + all, err := optFlag(args, "all", false) + if err != nil { + return nil, err + } + return Cancel(job, p, all) + }, + }, + { + Name: "cups_print", + Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " + + "sides=two-sided-long-edge or media=A4. Answers the job id. (a)", + Input: map[string]any{ + "file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"}, + "printer": printerArg, + "copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"}, + "options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"}, + "title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"}, + }, + Run: func(args map[string]any) (any, error) { + file, err := text(args, "file") + if err != nil { + return nil, err + } + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + copies, err := optWhole(args, "copies", 1, 1, 99) + if err != nil { + return nil, err + } + opts, err := optionsOf(args) + if err != nil { + return nil, err + } + title, err := optText(args, "title", "") + if err != nil { + return nil, err + } + return Print(file, p, copies, opts, title) + }, + }, + { + Name: "cups_default", + Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " + + "The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)", + Input: map[string]any{"printer": printerArg}, + Run: func(args map[string]any) (any, error) { + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + return Default(p) + }, + }, + { + Name: "cups_resume", + Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)", + Input: map[string]any{"printer": printerArg}, + Run: func(args map[string]any) (any, error) { + p, err := text(args, "printer") + if err != nil { + return nil, err + } + return Resume(p) + }, + }, + { + Name: "cups_drivers", + Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " + + "and backends, which of them are from outside the official repositories, and the driver models CUPS " + + "offers, filtered by match. (r)", + Input: map[string]any{ + "match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"}, + "limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"}, + }, + Run: func(args map[string]any) (any, error) { + match, err := optText(args, "match", "") + if err != nil { + return nil, err + } + limit, err := optWhole(args, "limit", 50, 0, 1000) + if err != nil { + return nil, err + } + return Drivers(match, limit) + }, + }, + } +} diff --git a/modules/cups/cmd/cups-tools/manifest_kit_test.go b/modules/cups/cmd/cups-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/cups/cmd/cups-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/cups/go.mod b/modules/cups/go.mod new file mode 100644 index 0000000..56eba25 --- /dev/null +++ b/modules/cups/go.mod @@ -0,0 +1,5 @@ +module cups + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/cups/go.sum b/modules/cups/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/cups/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/cups/module.json b/modules/cups/module.json new file mode 100644 index 0000000..01a4822 --- /dev/null +++ b/modules/cups/module.json @@ -0,0 +1,58 @@ +{ + "module": "cups", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "cups_printers", + "cups_queue", + "cups_cancel", + "cups_print", + "cups_default", + "cups_resume", + "cups_drivers" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "cups" + }, + { + "id": "driverless", + "type": "package", + "package": "cups-filters" + }, + { + "id": "socket", + "type": "service", + "unit": "cups.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "scheduler", + "type": "service", + "unit": "cups.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/cups-tools", + "binary": "cups-tools", + "loads": [ + "cups-tools" + ] + } + ] + } +} diff --git a/modules/dmenu/README.md b/modules/dmenu/README.md new file mode 100644 index 0000000..00e2e11 --- /dev/null +++ b/modules/dmenu/README.md @@ -0,0 +1,56 @@ +# dmenu + +The suckless menu, as a module (novox/hq research 026/04: "`dmenu` is a module of its own (official +repositories), able to hold the same seat on a machine that wants it"; to-be 42 phase 2 step 7). + +## Owns + +| what | where | +|---|---| +| `dmenu`, `dmenu_run`, `dmenu_path`, `stest` | package `dmenu` (official repositories) | + +## Improves: two broken calls work by existing + +Research 026/04 measured "plain `dmenu` in two places" in the operator's configuration, with dmenu +installed on neither workstation, so both failed. Measured again on 2026-10-04, the two are one line on +each workstation, in the notifier's configuration: + +- `~/.config/dunst/dunstrc`: `dmenu = /usr/bin/dmenu -p dunst:` + +It is the menu dunst opens to pick a notification's action or link. Every other menu in the +operator's scripts calls `rofi -dmenu`. Once this module installs the package, `/usr/bin/dmenu` exists +and that menu opens. The `dunst` module may later point the line at the launcher seat's command +instead (below). The line is that module's to own, so this module does not touch it. + +## The seat it would hold: not claimed yet + +Research 026/04 gives `node-launcher` to `rofi`, with a dmenu-compatible command as part of its +protocol, and lets `dmenu` hold the same seat on a machine that wants it. **The seat is not in the +controller's seat table yet**, and a claim on an unknown seat is refused. So this module claims +nothing. Its tool `dmenu_menu` is shaped like the seat's `menu` verb (research 026/05): show a list, +answer the chosen line. When the seat is recorded, the claim is one line here, serving `menu`. + +## Tools + +All answer JSON. `(r)` reads; `(d)` acts in the operator's session. + +| tool | what | +|---|---| +| `dmenu_menu` (d) | show up to 1000 choices, with a prompt, as one line or a vertical list, case-insensitive by default. Answers the chosen line and its index; a typed line that is not a choice (`typed`); `cancelled` when dismissed; `timed_out` when not answered in time (default 20 s, at most 25, below the runtime's 30 s call limit) | +| `dmenu_session` (r) | the session the menu would appear in and how it was found, or why there is none; dmenu's version | + +**Reaching the session** works as the `xclip` module's README describes: the runtime runs as the +account with no session words, and `session.go` finds the account's display and cookie from its own +processes. With no session, the tool says so and shows nothing. A keyboard held by another program +(a locked screen, an open menu) is answered as such. + +The menu draws in fontconfig's `monospace` at dmenu's default size, which the `fonts` module makes +JetBrains Mono Nerd Font. + +## What changes when it is assigned + +On both workstations, the `dmenu` package is installed, which neither has today. Nothing else. + +## Leaves as found + +The notifier's configuration, and every `rofi -dmenu` call in the operator's scripts. diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu.go b/modules/dmenu/cmd/dmenu-tools/dmenu.go new file mode 100644 index 0000000..4105d1e --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/dmenu.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered +// menu is answered as such rather than as a call the runtime gave up on. +const MostWait = 25 + +// MostChoices bounds the list. +const MostChoices = 1000 + +// Ask is one menu. +type Ask struct { + Choices []string + Prompt string + Lines int + CaseInsensitive bool + Timeout int +} + +// MenuAnswer is what dmenu_menu answers. +type MenuAnswer struct { + Chosen string `json:"chosen,omitempty"` + Index int `json:"index"` + Typed bool `json:"typed"` + Cancelled bool `json:"cancelled"` + TimedOut bool `json:"timed_out"` + Session Session `json:"session"` +} + +// Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and +// exits 0; it exits 1 with nothing printed when dismissed. +func Menu(a Ask) (MenuAnswer, error) { + if len(a.Choices) == 0 { + return MenuAnswer{}, fmt.Errorf("choices is required: at least one line") + } + if len(a.Choices) > MostChoices { + return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices) + } + for _, c := range a.Choices { + if strings.ContainsAny(c, "\n\r") { + return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c) + } + } + if strings.ContainsAny(a.Prompt, "\n\r") { + return MenuAnswer{}, fmt.Errorf("the prompt holds a line break") + } + s, err := findSession() + if err != nil { + return MenuAnswer{}, err + } + args := []string{} + if a.CaseInsensitive { + args = append(args, "-i") + } + if a.Lines > 0 { + args = append(args, "-l", strconv.Itoa(a.Lines)) + } + if a.Prompt != "" { + args = append(args, "-p", a.Prompt) + } + c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second} + r := run(c) + out := MenuAnswer{Index: -1, Session: s} + switch { + case r.Error == "timeout": + out.TimedOut = true + return out, nil + case r.Error != "": + return MenuAnswer{}, failure(c, r) + case strings.Contains(r.Stderr, "cannot open display"): + return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy) + case strings.Contains(r.Stderr, "cannot grab keyboard"): + return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)") + case r.Status == 1 && strings.TrimSpace(r.Stdout) == "": + out.Cancelled = true + return out, nil + case r.Status != 0: + return MenuAnswer{}, failure(c, r) + } + out.Chosen = strings.TrimRight(r.Stdout, "\r\n") + for i, ch := range a.Choices { + if ch == out.Chosen { + out.Index = i + break + } + } + out.Typed = out.Index < 0 + return out, nil +} + +// SessionCheck answers the session the menu would appear in, and dmenu's version. +func SessionCheck() (map[string]any, error) { + out := map[string]any{} + if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" { + out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr) + } else { + out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error() + } + s, err := findSession() + if err != nil { + out["found"], out["why"] = false, err.Error() + return out, nil + } + out["found"], out["session"] = true, s + return out, nil +} diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu_test.go b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go new file mode 100644 index 0000000..0e0e584 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestTheManifestIsThePackageAndClaimsNoSeatYet(t *testing.T) { + m := readManifest(t) + // holdsTheBundle also holds it to no claim: node-launcher is not in the controller's seat table + // yet (README). + holdsTheBundle(t, m, "dmenu") + if got := strings.Join(m.packages(), ","); got != "dmenu" || len(m.Resources) != 1 { + t.Errorf("packages %s, resources %v", got, m.Resources) + } +} + +func aSession(t *testing.T) { + t.Helper() + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"}) + aSocket(t, sockets, "X1") +} + +func TestMenuShowsTheChoicesInTheSessionAndAnswersTheOneTaken(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("Lock\n") }) + got, err := Menu(Ask{Choices: []string{"Shutdown", "Lock"}, Prompt: "power:", Lines: 5, CaseInsensitive: true, Timeout: 20}) + if err != nil || got.Chosen != "Lock" || got.Index != 1 || got.Typed || got.Cancelled || got.TimedOut { + t.Fatalf("%+v %v", got, err) + } + c := f.asked[0] + if f.lines()[0] != "dmenu -i -l 5 -p power:" || c.Stdin != "Shutdown\nLock\n" || c.Timeout != 20*time.Second { + t.Errorf("%v %+v", f.lines(), c) + } + if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" { + t.Errorf("env %v", c.Env) + } +} + +func TestMenuTellsTypedDismissedAndUnansweredApart(t *testing.T) { + aSession(t) + answer := ok("something else\n") + using(t, func(string, Cmd) Result { return answer }) + got, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.Typed || got.Index != -1 || got.Chosen != "something else" { + t.Errorf("typed: %+v %v", got, err) + } + answer = Result{Status: 1} + got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.Cancelled || got.Chosen != "" { + t.Errorf("dismissed: %+v %v", got, err) + } + answer = Result{Status: 124, Error: "timeout"} + got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.TimedOut { + t.Errorf("unanswered: %+v %v", got, err) + } + answer = Result{Status: 1, Stderr: "cannot grab keyboard\n"} + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "keyboard") { + t.Errorf("a held keyboard: %v", err) + } + answer = Result{Status: 1, Stderr: "cannot open display\n"} + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("a refusing display: %v", err) + } +} + +func TestMenuRefusesWhatCannotBeShownAndRunsNothingWithoutASession(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("") }) + for _, bad := range []Ask{{}, {Choices: []string{"a\nb"}}, {Choices: []string{"a"}, Prompt: "x\ny"}, {Choices: make([]string, MostChoices+1)}} { + if _, err := Menu(bad); err == nil { + t.Errorf("%+v accepted", bad) + } + } + aMachine(t, map[string]string{}) + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Errorf("%v", err) + } + if len(f.asked) != 0 { + t.Errorf("ran %v", f.lines()) + } +} + +func TestTheWaitIsBelowTheRuntimesCallLimit(t *testing.T) { + if MostWait >= 30 { + t.Fatalf("a menu may stay open %d s; the runtime gives a call 30", MostWait) + } + n, _ := optWhole(map[string]any{"timeout_seconds": float64(600)}, "timeout_seconds", 20, 1, MostWait) + if n != MostWait { + t.Errorf("%d", n) + } +} + +func TestSessionCheckSaysTheVersionAndTheSession(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return ok("dmenu-5.4\n") }) + got, err := SessionCheck() + if err != nil || got["dmenu"] != "dmenu-5.4" || got["found"] != true { + t.Fatalf("%v %v", got, err) + } + using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} }) + got, _ = SessionCheck() + if !strings.Contains(got["dmenu"].(string), "not installed") { + t.Errorf("%v", got) + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/kit.go b/modules/dmenu/cmd/dmenu-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/dmenu/cmd/dmenu-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/dmenu/cmd/dmenu-tools/kit_test.go b/modules/dmenu/cmd/dmenu-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-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/dmenu/cmd/dmenu-tools/main.go b/modules/dmenu/cmd/dmenu-tools/main.go new file mode 100644 index 0000000..d98a193 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/main.go @@ -0,0 +1,70 @@ +// The dmenu module's tool (novox/hq research 026/04, 026/05): show the operator a menu of choices in +// the graphical session and answer the one chosen — the dmenu-compatible command as a tool. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account +// and reaches the account's X session as session.go finds it; with no session, it says so. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "dmenu": "the dmenu package, which this module installs", +} + +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: "dmenu_menu", + Description: "Show the operator a menu of choices on their screen and answer the line chosen, its index, or " + + "that the menu was dismissed or not answered in time (default 20 s, at most 25). The operator may also " + + "type a line that is not a choice. Needs the operator's graphical session. (d)", + Input: map[string]any{ + "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the lines to choose from, at most 1000, none with a line break"}, + "prompt": map[string]any{"type": "string", "description": "a prompt shown left of the input"}, + "lines": map[string]any{"type": "integer", "description": "show the choices as a vertical list of this many lines (default 0: one horizontal line; at most 40)"}, + "case_insensitive": map[string]any{"type": "boolean", "description": "match what is typed regardless of case (default true)"}, + "timeout_seconds": map[string]any{"type": "integer", "description": "close the menu unanswered after this long (default 20, at most 25)"}, + }, + Run: func(args map[string]any) (any, error) { + choices, err := optList(args, "choices") + if err != nil { + return nil, err + } + prompt, err := optText(args, "prompt", "") + if err != nil { + return nil, err + } + n, err := optWhole(args, "lines", 0, 0, 40) + if err != nil { + return nil, err + } + ci, err := optFlag(args, "case_insensitive", true) + if err != nil { + return nil, err + } + timeout, err := optWhole(args, "timeout_seconds", 20, 1, MostWait) + if err != nil { + return nil, err + } + return Menu(Ask{Choices: choices, Prompt: prompt, Lines: n, CaseInsensitive: ci, Timeout: timeout}) + }, + }, + { + Name: "dmenu_session", + Description: "Which X session the menu would appear in and how it was found, or why there is none; and dmenu's version. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return SessionCheck() }, + }, + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go b/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-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/dmenu/cmd/dmenu-tools/session.go b/modules/dmenu/cmd/dmenu-tools/session.go new file mode 100644 index 0000000..43ba119 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/session.go @@ -0,0 +1,177 @@ +package main + +// session.go is the same file in the bundles whose tools act in the operator's graphical session +// (xclip, dmenu): how a process the node's tool runtime launched reaches that session. +// +// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the +// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X +// server accepts a client that names its display and presents the cookie in the authority file, and +// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is +// readable by the account. So the session is found, not configured: +// +// 1. the process's own DISPLAY, when the runtime happens to have one; +// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from +// /proc//environ (the window manager's, by preference), whose socket exists; +// 3. else the only X socket there is, with the authority file in the account's home. +// +// When none is found the tool says that no graphical session of the account is running, and does +// nothing. + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + +// Session is the operator's X session as a tool reaches it. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + // FoundBy says how: "environment", "process ()" or "socket". + FoundBy string `json:"found_by"` +} + +// Env is what a command needs to reach the session. +func (s Session) Env() []string { + env := []string{"DISPLAY=" + s.Display} + if s.XAuthority != "" { + env = append(env, "XAUTHORITY="+s.XAuthority) + } + return env +} + +// Where the session is looked for. Tests point these at a tree of their own. +var ( + procRoot = "/proc" + x11Sockets = "/tmp/.X11-unix" + getenv = os.Getenv + myUID = os.Getuid +) + +// sessionWMs are the programs whose environment is the session's own, preferred over any other +// process's (a terminal's child may carry a stale or forwarded DISPLAY). +var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true} + +func accountHome() string { + if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + if h := strings.TrimSpace(getenv("HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one. +func socketOf(display string) string { + if !strings.HasPrefix(display, ":") { + return "" + } + n := strings.TrimPrefix(display, ":") + if i := strings.IndexByte(n, '.'); i >= 0 { + n = n[:i] + } + if _, err := strconv.Atoi(n); err != nil { + return "" + } + return filepath.Join(x11Sockets, "X"+n) +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// findSession answers the account's X session, or an error saying there is none. +func findSession() (Session, error) { + if d := strings.TrimSpace(getenv("DISPLAY")); d != "" { + if s := socketOf(d); s == "" || exists(s) { + return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil + } + } + type seen struct { + Session + wm bool + count int + } + found := map[string]*seen{} + entries, _ := os.ReadDir(procRoot) + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || !e.IsDir() { + continue + } + dir := filepath.Join(procRoot, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + var display, auth string + for _, kv := range strings.Split(string(raw), "\x00") { + switch { + case strings.HasPrefix(kv, "DISPLAY="): + display = strings.TrimPrefix(kv, "DISPLAY=") + case strings.HasPrefix(kv, "XAUTHORITY="): + auth = strings.TrimPrefix(kv, "XAUTHORITY=") + } + } + if display == "" { + continue + } + if s := socketOf(display); s == "" || !exists(s) { + continue + } + comm, _ := os.ReadFile(filepath.Join(dir, "comm")) + name := strings.TrimSpace(string(comm)) + key := display + "\x00" + auth + if found[key] == nil { + found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}} + } + f := found[key] + f.count++ + if sessionWMs[name] && !f.wm { + f.wm = true + f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name) + } + } + if len(found) > 0 { + all := make([]*seen, 0, len(found)) + for _, f := range found { + all = append(all, f) + } + sort.Slice(all, func(i, k int) bool { + if all[i].wm != all[k].wm { + return all[i].wm + } + if all[i].count != all[k].count { + return all[i].count > all[k].count + } + return all[i].Display < all[k].Display + }) + return all[0].Session, nil + } + sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*")) + if len(sockets) == 1 { + s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"} + if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) { + s.XAuthority = a + } + return s, nil + } + if len(sockets) > 1 { + return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets) + } + return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets) +} diff --git a/modules/dmenu/cmd/dmenu-tools/session_test.go b/modules/dmenu/cmd/dmenu-tools/session_test.go new file mode 100644 index 0000000..707e754 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/session_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// aMachine gives findSession a /proc and an X socket directory of the test's own. +func aMachine(t *testing.T, env map[string]string) (proc, sockets string) { + t.Helper() + root := t.TempDir() + proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11") + for _, d := range []string{proc, sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv + procRoot, x11Sockets = proc, sockets + getenv = func(k string) string { return env[k] } + t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv }) + return proc, sockets +} + +func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644) +} + +func aSocket(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) { + proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"}) + aSocket(t, sockets, "X1") + aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale") + aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 6, "sshd", "PATH=/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") { + t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s) + } + if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" { + t.Fatalf("env %s", got) + } +} + +func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) { + home := t.TempDir() + _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600) + _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home}) + aSocket(t, sockets, "X0") + s, err := findSession() + if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) { + _, sockets := aMachine(t, map[string]string{}) + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("none: %v", err) + } + aSocket(t, sockets, "X0") + aSocket(t, sockets, "X1") + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") { + t.Fatalf("two: %v", err) + } +} + +func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) { + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"}) + aSocket(t, sockets, "X2") + s, err := findSession() + if err != nil || s.Display != ":2" || s.FoundBy != "environment" { + t.Fatalf("%+v %v", s, err) + } +} diff --git a/modules/dmenu/go.mod b/modules/dmenu/go.mod new file mode 100644 index 0000000..8cf0001 --- /dev/null +++ b/modules/dmenu/go.mod @@ -0,0 +1,5 @@ +module dmenu + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/dmenu/go.sum b/modules/dmenu/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/dmenu/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/dmenu/module.json b/modules/dmenu/module.json new file mode 100644 index 0000000..aeeb3ce --- /dev/null +++ b/modules/dmenu/module.json @@ -0,0 +1,33 @@ +{ + "module": "dmenu", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "dmenu_menu", + "dmenu_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dmenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dmenu-tools", + "binary": "dmenu-tools", + "loads": [ + "dmenu-tools" + ] + } + ] + } +} diff --git a/modules/docker-compose/README.md b/modules/docker-compose/README.md new file mode 100644 index 0000000..e712833 --- /dev/null +++ b/modules/docker-compose/README.md @@ -0,0 +1,65 @@ +# docker-compose + +Compose, for development work on the two workstations (novox/hq research 027/02: "the distribution's +package and nothing else", assigned only to the workstations; to-be 42 phase 2 step 9). The servers +run nothing through compose. + +## Owns + +| what | where | +|---|---| +| compose, as the container runtime's plugin (`docker compose`) and as `docker-compose` | package `docker-compose` | + +Nothing else. The runtime, its configuration, buildx and the `docker` group are the `docker` module's +(to-be 42 phase 1 step 8). This module needs that runtime on the machine. Until the `docker` module +holds `node-container-runtime` there, nothing in the mesh says so, and the tools answer that the +runtime is missing or unreachable rather than an empty list. + +## Improves + +- **An owner for a package both workstations already carry.** On both, `docker-compose` 5.5.0 is + installed explicitly by hand, as the plugin in `/usr/lib/docker/cli-plugins`. Assigning the module + changes nothing on disk; from then on the package is the mesh's, upgraded with the machine and + given back when the module goes. +- **The projects become visible from the mesh** without a shell on the machine: which are running, + where their files are, their containers, logs and rendered configuration. +- **No account-level copy of the plugin** exists on either workstation (`~/.docker/cli-plugins` is + empty), so there is no second compose to remove. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account, which reaches the runtime +through the `docker` group. Nothing goes through `sudo`. + +A project is named by `dir`, its absolute directory, or by `project`, its name. A directory docker +already knows a project for is that project, with the files it was started from, overrides included. +Any other directory must hold a compose file. + +| tool | what | +|---|---| +| `docker_compose_projects` (r) | every project docker knows, running or stopped: status, working directory (from the containers' labels), compose files, services, containers running of total | +| `docker_compose_ps` (r) | one project's containers: service, state, health, exit code, image, published ports | +| `docker_compose_logs` (r) | the last lines per service (default 200, at most 5000), optionally `since`; cut at 256 KiB | +| `docker_compose_config` (r) | the rendered configuration. Values of environment variables, build arguments and labels whose names suggest a secret, and inline secret or config content, are replaced with `[redacted]`, and the answer counts them | +| `docker_compose_up` (a) | `up --detach`, with the pull policy (default `missing`) and optionally `--build`, for all or some services | +| `docker_compose_down` (a) | `down`: containers and networks. **Volumes are kept**: no tool here removes data | +| `docker_compose_restart` (a) | restart all or some services | +| `docker_compose_pull` (a) | pull images without starting anything | +| `docker_compose_job` (r) | a long act's state: running or finished, exit status, the end of its output; without an id, every act this process knows | + +**Acts are jobs.** An `up` that pulls or builds takes minutes, and the runtime gives a call 30 s. Each +act runs inside the tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is +answered with its output, and a failed one as an error. One still running is answered with its job id, +which `docker_compose_job` follows. A job ends if the runtime restarts the bundle. + +## Leaves as found + +The projects themselves are the operator's work, under the operator's directories. Measured on +2026-10-04: + +- **laptop:** `anton-lavinmq` and `anton-traefik` running, `anton-redis` stopped. +- **desktop:** `anton-lavinmq`, `lavinmq` (from `/services/lavinmq`, a predecessor's directory) and + `registry` running. + +Whether the desktop's `lavinmq` and `registry` should still run is the operator's call; +`docker_compose_down` with their directory stops them. diff --git a/modules/docker-compose/cmd/docker-compose-tools/compose.go b/modules/docker-compose/cmd/docker-compose-tools/compose.go new file mode 100644 index 0000000..566b367 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-tools/compose.go @@ -0,0 +1,455 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Project is one compose project as docker knows it. +type Project struct { + Name string `json:"name"` + Status string `json:"status,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + ConfigFiles []string `json:"config_files"` + Services []string `json:"services"` + Running int `json:"running"` + Containers int `json:"containers"` +} + +// ProjectsAnswer is what docker_compose_projects answers. +type ProjectsAnswer struct { + Count int `json:"count"` + Projects []Project `json:"projects"` +} + +// composeFiles are the names compose looks for in a directory, in its order. +var composeFiles = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"} + +// statDir says whether a path is a directory. Tests replace it. +var statDir = func(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + +// statFile says whether a path is a regular file. Tests replace it. +var statFile = func(p string) bool { + info, err := os.Stat(p) + return err == nil && info.Mode().IsRegular() +} + +// docker runs one docker command and names a daemon the account cannot reach as such. +func docker(args ...string) (Result, error) { + r, err := call(Cmd{Name: "docker", Args: args}) + if err != nil && strings.Contains(r.Stderr, "permission denied") && strings.Contains(r.Stderr, "docker.sock") { + return r, fmt.Errorf("the account cannot reach the container runtime's socket (permission denied): it is not in the docker group, or has not logged in since it was added. The docker module owns the group's members") + } + if err != nil && strings.Contains(r.Stderr, "Cannot connect to the Docker daemon") { + return r, fmt.Errorf("the container runtime is not running on this machine: %s", firstLine(r.Stderr)) + } + return r, err +} + +// Projects merges what compose lists with what the containers' labels say. +func Projects() (ProjectsAnswer, error) { + r, err := docker("compose", "ls", "--all", "--format", "json") + if err != nil { + return ProjectsAnswer{}, err + } + var listed []struct { + Name string `json:"Name"` + Status string `json:"Status"` + ConfigFiles string `json:"ConfigFiles"` + } + if s := strings.TrimSpace(r.Stdout); s != "" { + if err := json.Unmarshal([]byte(s), &listed); err != nil { + return ProjectsAnswer{}, fmt.Errorf("docker compose ls answered what is not JSON: %v", err) + } + } + by := map[string]*Project{} + get := func(name string) *Project { + if by[name] == nil { + by[name] = &Project{Name: name, ConfigFiles: []string{}, Services: []string{}} + } + return by[name] + } + for _, l := range listed { + p := get(l.Name) + p.Status = l.Status + p.ConfigFiles = splitFiles(l.ConfigFiles) + } + r, err = docker("ps", "-a", "--filter", "label=com.docker.compose.project", "--format", + `{{.Label "com.docker.compose.project"}}`+"\t"+`{{.Label "com.docker.compose.project.working_dir"}}`+"\t"+ + `{{.Label "com.docker.compose.project.config_files"}}`+"\t"+`{{.Label "com.docker.compose.service"}}`+"\t{{.State}}") + if err != nil { + return ProjectsAnswer{}, err + } + services := map[string]map[string]bool{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) < 5 || f[0] == "" { + continue + } + p := get(f[0]) + if p.WorkingDir == "" { + p.WorkingDir = f[1] + } + if len(p.ConfigFiles) == 0 { + p.ConfigFiles = splitFiles(f[2]) + } + if services[f[0]] == nil { + services[f[0]] = map[string]bool{} + } + if f[3] != "" { + services[f[0]][f[3]] = true + } + p.Containers++ + if f[4] == "running" { + p.Running++ + } + } + out := ProjectsAnswer{Projects: []Project{}} + for name, p := range by { + for s := range services[name] { + p.Services = append(p.Services, s) + } + sort.Strings(p.Services) + if p.WorkingDir == "" && len(p.ConfigFiles) > 0 { + p.WorkingDir = filepath.Dir(p.ConfigFiles[0]) + } + out.Projects = append(out.Projects, *p) + } + sort.Slice(out.Projects, func(i, k int) bool { return out.Projects[i].Name < out.Projects[k].Name }) + out.Count = len(out.Projects) + return out, nil +} + +func splitFiles(s string) []string { + out := []string{} + for _, f := range strings.Split(s, ",") { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +// Target is the project a tool acts on, and how compose is told which it is. +type Target struct { + Project string `json:"project,omitempty"` + Dir string `json:"dir,omitempty"` + Files []string `json:"files,omitempty"` +} + +// args are compose's own options naming the target. +func (t Target) args() []string { + out := []string{"compose"} + if t.Dir != "" { + out = append(out, "--project-directory", t.Dir) + } + for _, f := range t.Files { + out = append(out, "-f", f) + } + if t.Project != "" { + out = append(out, "-p", t.Project) + } + return out +} + +var projectName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// targetOf reads dir or project. A directory docker already knows a project for is that project, +// with the files it was started from; otherwise it must hold a compose file. needFiles says the +// tool reads the files (config, up, pull), so a project known only by its containers is not enough. +func targetOf(args map[string]any, needFiles bool) (Target, error) { + dir, err := optText(args, "dir", "") + if err != nil { + return Target{}, err + } + name, err := optText(args, "project", "") + if err != nil { + return Target{}, err + } + if dir == "" && name == "" { + return Target{}, fmt.Errorf("give dir, the project's directory, or project, its name") + } + if dir != "" { + if !filepath.IsAbs(dir) { + return Target{}, fmt.Errorf("dir must be an absolute path, not %q", dir) + } + dir = filepath.Clean(dir) + if !statDir(dir) { + return Target{}, fmt.Errorf("%s is not a directory on this machine", dir) + } + } + if name != "" && !projectName.MatchString(name) { + return Target{}, fmt.Errorf("%q is not a compose project name", name) + } + known, err := Projects() + if err != nil { + return Target{}, err + } + for _, p := range known.Projects { + if (dir != "" && p.WorkingDir == dir) || (dir == "" && p.Name == name) { + if name != "" && p.Name != name { + continue + } + t := Target{Project: p.Name, Dir: p.WorkingDir} + present := len(p.ConfigFiles) > 0 + for _, f := range p.ConfigFiles { + present = present && statFile(f) + } + if present { + t.Files = p.ConfigFiles + } else if needFiles && !hasComposeFile(t.Dir) { + return Target{}, fmt.Errorf("project %s was started from %s, which is no longer there", p.Name, strings.Join(p.ConfigFiles, ", ")) + } + return t, nil + } + } + if dir == "" { + return Target{}, fmt.Errorf("no compose project named %s is known to docker here: give dir, its directory", name) + } + if !hasComposeFile(dir) { + return Target{}, fmt.Errorf("%s holds no compose file (%s)", dir, strings.Join(composeFiles, ", ")) + } + return Target{Dir: dir, Project: name}, nil +} + +func hasComposeFile(dir string) bool { + for _, f := range composeFiles { + if statFile(filepath.Join(dir, f)) { + return true + } + } + return false +} + +// Container is one of a project's containers. +type Container struct { + Name string `json:"name"` + Service string `json:"service"` + State string `json:"state"` + Status string `json:"status"` + Health string `json:"health,omitempty"` + ExitCode int `json:"exit_code"` + Image string `json:"image"` + Ports []string `json:"ports"` +} + +// PsAnswer is what docker_compose_ps answers. +type PsAnswer struct { + Target Target `json:"target"` + Containers []Container `json:"containers"` +} + +// jsonObjects reads compose's JSON output, which is one array or one object per line by version. +func jsonObjects(s string, into any) error { + s = strings.TrimSpace(s) + if s == "" { + s = "[]" + } + if !strings.HasPrefix(s, "[") { + s = "[" + strings.Join(lines(s), ",") + "]" + } + return json.Unmarshal([]byte(s), into) +} + +// Ps answers a project's containers. +func Ps(t Target) (PsAnswer, error) { + r, err := docker(append(t.args(), "ps", "-a", "--format", "json")...) + if err != nil { + return PsAnswer{}, err + } + var raw []struct { + Name string `json:"Name"` + Service string `json:"Service"` + State string `json:"State"` + Status string `json:"Status"` + Health string `json:"Health"` + ExitCode int `json:"ExitCode"` + Image string `json:"Image"` + Publishers []struct { + URL string `json:"URL"` + TargetPort int `json:"TargetPort"` + PublishedPort int `json:"PublishedPort"` + Protocol string `json:"Protocol"` + } `json:"Publishers"` + } + if err := jsonObjects(r.Stdout, &raw); err != nil { + return PsAnswer{}, fmt.Errorf("docker compose ps answered what is not JSON: %v", err) + } + out := PsAnswer{Target: t, Containers: []Container{}} + for _, c := range raw { + ports := []string{} + for _, p := range c.Publishers { + if p.PublishedPort == 0 { + continue + } + ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.URL, p.PublishedPort, p.TargetPort, p.Protocol)) + } + out.Containers = append(out.Containers, Container{Name: c.Name, Service: c.Service, State: c.State, Status: c.Status, + Health: c.Health, ExitCode: c.ExitCode, Image: c.Image, Ports: ports}) + } + return out, nil +} + +// LogsAnswer is what docker_compose_logs answers. +type LogsAnswer struct { + Target Target `json:"target"` + Lines []string `json:"lines"` + Truncated bool `json:"truncated,omitempty"` +} + +var since = regexp.MustCompile(`^[0-9A-Za-z:.+-]+$`) + +// Logs answers a project's last lines. +func Logs(t Target, services []string, n int, from string) (LogsAnswer, error) { + args := append(t.args(), "logs", "--no-color", "--timestamps", "--tail", fmt.Sprint(n)) + if from != "" { + if !since.MatchString(from) { + return LogsAnswer{}, fmt.Errorf("since %q is neither a duration nor a timestamp", from) + } + args = append(args, "--since", from) + } + for _, s := range services { + if err := plainName("service", s); err != nil { + return LogsAnswer{}, err + } + } + r, err := docker(append(args, services...)...) + if err != nil { + return LogsAnswer{}, err + } + // compose writes the containers' output on its stdout, and its own complaints on stderr. + return LogsAnswer{Target: t, Lines: lines(r.Stdout), Truncated: r.Truncated}, nil +} + +// ConfigAnswer is what docker_compose_config answers. +type ConfigAnswer struct { + Target Target `json:"target"` + Services []string `json:"services"` + Redacted int `json:"redacted"` + Rendered map[string]any `json:"rendered"` +} + +// secretish is a name whose value is not shown. +var secretish = regexp.MustCompile(`(?i)(pass|secret|token|key|credential|auth|private|cert|cookie|session|salt|dsn|api)`) + +// redact replaces the values of secret-looking names in the maps compose renders. +func redact(v any, count *int) { + switch x := v.(type) { + case map[string]any: + for k, child := range x { + switch k { + case "environment", "args", "labels", "build_args": + if m, ok := child.(map[string]any); ok { + for name, val := range m { + if val != nil && secretish.MatchString(name) { + m[name] = "[redacted]" + *count++ + } + } + continue + } + case "content": + // An inline config or secret: its content is the secret itself. + if _, ok := child.(string); ok { + x[k] = "[redacted]" + *count++ + continue + } + } + redact(child, count) + } + case []any: + for _, child := range x { + redact(child, count) + } + } +} + +// Config answers the rendered configuration. +func Config(t Target) (ConfigAnswer, error) { + r, err := docker(append(t.args(), "config", "--format", "json")...) + if err != nil { + return ConfigAnswer{}, err + } + var rendered map[string]any + if err := json.Unmarshal([]byte(r.Stdout), &rendered); err != nil { + return ConfigAnswer{}, fmt.Errorf("docker compose config answered what is not JSON: %v", err) + } + out := ConfigAnswer{Target: t, Services: []string{}, Rendered: rendered} + if s, ok := rendered["services"].(map[string]any); ok { + for name := range s { + out.Services = append(out.Services, name) + } + sort.Strings(out.Services) + } + redact(rendered, &out.Redacted) + return out, nil +} + +// ActAnswer is what an act answers: the target and the job that carries it. +type ActAnswer struct { + Act string `json:"act"` + Target Target `json:"target"` + Job Job `json:"job"` +} + +func act(name string, t Target, extra ...string) (ActAnswer, error) { + j, err := actAsJob(Cmd{Name: "docker", Args: append(append(t.args(), name), extra...)}) + if err != nil { + return ActAnswer{}, err + } + return ActAnswer{Act: name, Target: t, Job: j}, nil +} + +func checkServices(services []string) error { + for _, s := range services { + if err := plainName("service", s); err != nil { + return err + } + } + return nil +} + +// Up brings a project up, detached. +func Up(t Target, services []string, build bool, pull string) (ActAnswer, error) { + if err := oneOf("pull", pull, "missing", "always", "never"); err != nil { + return ActAnswer{}, err + } + if err := checkServices(services); err != nil { + return ActAnswer{}, err + } + extra := []string{"--detach", "--pull", pull} + if build { + extra = append(extra, "--build") + } + return act("up", t, append(extra, services...)...) +} + +// Down stops and removes a project's containers and networks, keeping its volumes. +func Down(t Target) (ActAnswer, error) { + return act("down", t) +} + +// Restart restarts a project's containers. +func Restart(t Target, services []string) (ActAnswer, error) { + if err := checkServices(services); err != nil { + return ActAnswer{}, err + } + return act("restart", t, services...) +} + +// Pull pulls a project's images. +func Pull(t Target, services []string) (ActAnswer, error) { + if err := checkServices(services); err != nil { + return ActAnswer{}, err + } + return act("pull", t, services...) +} diff --git a/modules/docker-compose/cmd/docker-compose-tools/compose_test.go b/modules/docker-compose/cmd/docker-compose-tools/compose_test.go new file mode 100644 index 0000000..750939a --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-tools/compose_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTheManifestIsComposesPackageAndNothingElse(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "docker_compose") + if got := strings.Join(m.packages(), ","); got != "docker-compose" { + t.Errorf("packages %s: buildx and the runtime are the docker module's", got) + } + if len(m.Resources) != 1 { + t.Errorf("one resource, the package: %v", m.Resources) + } +} + +const lsJSON = `[{"Name":"anton-lavinmq","Status":"running(1)","ConfigFiles":"/home/op/hub/lavinmq/docker-compose.yml"},{"Name":"old","Status":"exited(2)","ConfigFiles":"/srv/old/compose.yaml,/srv/old/compose.override.yaml"}]` + +const psLabels = "anton-lavinmq\t/home/op/hub/lavinmq\t/home/op/hub/lavinmq/docker-compose.yml\tlavinmq\trunning\n" + + "old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tweb\texited\n" + + "old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tdb\texited\n" + +// aDocker answers compose ls and the labelled ps, and hands every other line to rest. +func aDocker(t *testing.T, rest func(line string) Result) *fake { + return using(t, func(line string, c Cmd) Result { + switch { + case strings.HasPrefix(line, "docker compose ls"): + return ok(lsJSON) + case strings.HasPrefix(line, "docker ps -a --filter label=com.docker.compose.project"): + return ok(psLabels) + } + if rest != nil { + return rest(line) + } + return ok("") + }) +} + +func onDisk(t *testing.T, dirs, files []string) { + t.Helper() + wasD, wasF := statDir, statFile + in := func(set []string) func(string) bool { + return func(p string) bool { + for _, s := range set { + if s == p { + return true + } + } + return false + } + } + statDir, statFile = in(dirs), in(files) + t.Cleanup(func() { statDir, statFile = wasD, wasF }) +} + +func TestProjectsMergesComposesListWithTheContainersLabels(t *testing.T) { + aDocker(t, nil) + got, err := Projects() + if err != nil || got.Count != 2 { + t.Fatalf("%+v %v", got, err) + } + p := got.Projects[0] + if p.Name != "anton-lavinmq" || p.WorkingDir != "/home/op/hub/lavinmq" || p.Running != 1 || p.Containers != 1 || p.Status != "running(1)" { + t.Errorf("%+v", p) + } + o := got.Projects[1] + if strings.Join(o.Services, ",") != "db,web" || len(o.ConfigFiles) != 2 || o.Running != 0 || o.Containers != 2 { + t.Errorf("%+v", o) + } +} + +func TestProjectsNamesADaemonTheAccountCannotReach(t *testing.T) { + using(t, func(string, Cmd) Result { + return Result{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"} + }) + if _, err := Projects(); err == nil || !strings.Contains(err.Error(), "docker group") { + t.Fatalf("%v", err) + } +} + +func TestATargetIsAKnownProjectWithItsFilesOrADirectoryHoldingAComposeFile(t *testing.T) { + aDocker(t, nil) + onDisk(t, []string{"/home/op/hub/lavinmq", "/srv/new", "/srv/empty", "/srv/old"}, + []string{"/home/op/hub/lavinmq/docker-compose.yml", "/srv/new/compose.yaml"}) + got, err := targetOf(map[string]any{"dir": "/home/op/hub/lavinmq/"}, true) + if err != nil || got.Project != "anton-lavinmq" || len(got.Files) != 1 { + t.Fatalf("a known directory: %+v %v", got, err) + } + if a := strings.Join(got.args(), " "); a != "compose --project-directory /home/op/hub/lavinmq -f /home/op/hub/lavinmq/docker-compose.yml -p anton-lavinmq" { + t.Errorf("args %s", a) + } + got, err = targetOf(map[string]any{"dir": "/srv/new"}, true) + if err != nil || got.Project != "" || got.Dir != "/srv/new" { + t.Fatalf("a new directory: %+v %v", got, err) + } + got, err = targetOf(map[string]any{"project": "old"}, false) + if err != nil || got.Dir != "/srv/old" || got.Files != nil { + t.Fatalf("a project whose files are gone, for an act that needs none: %+v %v", got, err) + } + if _, err := targetOf(map[string]any{"project": "old"}, true); err == nil || !strings.Contains(err.Error(), "no longer there") { + t.Errorf("its files are needed: %v", err) + } + for _, bad := range []map[string]any{{}, {"dir": "rel/path"}, {"dir": "/nope"}, {"dir": "/srv/empty"}, {"project": "Bad Name"}, {"project": "unknown"}} { + if _, err := targetOf(bad, false); err == nil { + t.Errorf("%v was accepted", bad) + } + } +} + +func TestPsReadsEitherJSONShapeAndKeepsOnlyPublishedPorts(t *testing.T) { + aDocker(t, func(line string) Result { + return ok(`{"Name":"a-web-1","Service":"web","State":"running","Status":"Up 2 hours","Health":"healthy","ExitCode":0,"Image":"nginx","Publishers":[{"URL":"0.0.0.0","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"},{"URL":"","TargetPort":443,"PublishedPort":0,"Protocol":"tcp"}]} +{"Name":"a-db-1","Service":"db","State":"exited","Status":"Exited (1)","ExitCode":1,"Image":"postgres","Publishers":[]}`) + }) + got, err := Ps(Target{Project: "a"}) + if err != nil || len(got.Containers) != 2 { + t.Fatalf("%+v %v", got, err) + } + if c := got.Containers[0]; strings.Join(c.Ports, ",") != "0.0.0.0:8080->80/tcp" || c.Health != "healthy" { + t.Errorf("%+v", c) + } + var arr []map[string]any + if err := jsonObjects(`[{"Name":"x"}]`, &arr); err != nil || len(arr) != 1 { + t.Errorf("an array: %v %v", arr, err) + } +} + +func TestLogsAreBoundedAndRefuseAnOptionInAName(t *testing.T) { + f := aDocker(t, func(line string) Result { return Result{Stdout: "web-1 | a\nweb-1 | b\n", Truncated: true} }) + got, err := Logs(Target{Project: "a"}, []string{"web"}, 50, "10m") + if err != nil || len(got.Lines) != 2 || !got.Truncated { + t.Fatalf("%+v %v", got, err) + } + if l := f.lines()[0]; l != "docker compose -p a logs --no-color --timestamps --tail 50 --since 10m web" { + t.Errorf("%s", l) + } + if _, err := Logs(Target{Project: "a"}, []string{"--follow"}, 5, ""); err == nil { + t.Error("an option as a service") + } + if _, err := Logs(Target{Project: "a"}, nil, 5, "1h; rm"); err == nil { + t.Error("a since that is neither") + } +} + +func TestConfigRedactsSecretLookingValuesAndInlineContent(t *testing.T) { + aDocker(t, func(string) Result { + return ok(`{"name":"a","services":{"web":{"image":"nginx","environment":{"DB_PASSWORD":"hunter2","PORT":"80","API_TOKEN":"t"},"build":{"args":{"NPM_TOKEN":"n","NODE_ENV":"production"}}}},"secrets":{"s":{"content":"raw"}}}`) + }) + got, err := Config(Target{Dir: "/srv/a"}) + if err != nil || got.Redacted != 4 || strings.Join(got.Services, ",") != "web" { + t.Fatalf("%+v %v", got, err) + } + raw, _ := json.Marshal(got.Rendered) + for _, secret := range []string{"hunter2", `"t"`, `"n"`, "raw"} { + if strings.Contains(string(raw), secret) { + t.Errorf("%s shown: %s", secret, raw) + } + } + for _, kept := range []string{`"PORT":"80"`, `"NODE_ENV":"production"`, `"image":"nginx"`} { + if !strings.Contains(string(raw), kept) { + t.Errorf("%s hidden: %s", kept, raw) + } + } +} + +func TestConfigAnInvalidFileIsComposesError(t *testing.T) { + aDocker(t, func(string) Result { + return Result{Status: 15, Stderr: "services.web Additional property foo is not allowed"} + }) + if _, err := Config(Target{Dir: "/srv/a"}); err == nil || !strings.Contains(err.Error(), "Additional property") { + t.Fatalf("%v", err) + } +} + +func TestActsAreJobsAsTheAccountKeepVolumesAndCheckTheirArguments(t *testing.T) { + f := aDocker(t, nil) + tg := Target{Dir: "/srv/a", Project: "a"} + if got, err := Up(tg, []string{"web"}, true, "always"); err != nil || got.Job.Running || got.Act != "up" { + t.Fatalf("%+v %v", got, err) + } + if _, err := Down(tg); err != nil { + t.Fatal(err) + } + if _, err := Restart(tg, nil); err != nil { + t.Fatal(err) + } + if _, err := Pull(tg, nil); err != nil { + t.Fatal(err) + } + want := []string{ + "docker compose --project-directory /srv/a -p a up --detach --pull always --build web", + "docker compose --project-directory /srv/a -p a down", + "docker compose --project-directory /srv/a -p a restart", + "docker compose --project-directory /srv/a -p a pull", + } + if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") { + t.Errorf("asked\n%s\nwant\n%s", got, strings.Join(want, "\n")) + } + for _, l := range f.lines() { + if strings.Contains(l, "sudo") || strings.Contains(l, "-v") || strings.Contains(l, "--volumes") { + t.Errorf("%s", l) + } + } + if _, err := Up(tg, nil, false, "sometimes"); err == nil { + t.Error("an unknown pull policy") + } + if _, err := Restart(tg, []string{"-t"}); err == nil { + t.Error("an option as a service") + } +} + +func TestAFailedActIsAnError(t *testing.T) { + aDocker(t, func(string) Result { + return Result{Status: 1, Stderr: "Error response from daemon: port is already allocated"} + }) + if _, err := Up(Target{Dir: "/srv/a"}, nil, false, "missing"); err == nil || !strings.Contains(err.Error(), "already allocated") { + t.Fatalf("%v", err) + } +} diff --git a/modules/docker-compose/cmd/docker-compose-tools/jobs.go b/modules/docker-compose/cmd/docker-compose-tools/jobs.go new file mode 100644 index 0000000..3885479 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-tools/jobs.go @@ -0,0 +1,151 @@ +package main + +// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose): +// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an +// act is started as a job inside this process, waited on for a while, and answered either finished +// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the +// runtime restarts the bundle, a running job is cut off, and its id is then unknown. + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id. +const ( + JobLimit = 15 * time.Minute + JobWait = 18 * time.Second + keptJobs = 50 +) + +// Job is one long act, as its tool answers it. +type Job struct { + ID string `json:"job"` + Command string `json:"command"` + Started time.Time `json:"started"` + Finished *time.Time `json:"finished,omitempty"` + Running bool `json:"running"` + Status *int `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Output string `json:"output,omitempty"` + Truncated bool `json:"truncated,omitempty"` + done chan struct{} +} + +type jobBook struct { + mu sync.Mutex + seq int + jobs map[string]*Job +} + +var jobs = &jobBook{jobs: map[string]*Job{}} + +// startJob runs c in the background, held to JobLimit. +func startJob(c Cmd) *Job { + c.Timeout = JobLimit + name, args := argv(c) + jobs.mu.Lock() + jobs.seq++ + j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")), + Started: time.Now().UTC(), Running: true, done: make(chan struct{})} + jobs.jobs[j.ID] = j + jobs.forgetOldest() + jobs.mu.Unlock() + go func() { + r := run(c) + var err error + if r.Status != 0 || r.Error != "" { + err = failure(c, r) + } + jobs.mu.Lock() + now := time.Now().UTC() + j.Finished, j.Running = &now, false + status := r.Status + j.Status = &status + if err != nil { + j.Error = err.Error() + } + j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10) + j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10 + jobs.mu.Unlock() + close(j.done) + }() + return j +} + +// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held. +func (b *jobBook) forgetOldest() { + if len(b.jobs) <= keptJobs { + return + } + all := make([]*Job, 0, len(b.jobs)) + for _, j := range b.jobs { + all = append(all, j) + } + sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) }) + for _, j := range all { + if len(b.jobs) <= keptJobs { + return + } + if !j.Running { + delete(b.jobs, j.ID) + } + } +} + +// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands. +func awaitJob(j *Job, d time.Duration) Job { + select { + case <-j.done: + case <-time.After(d): + } + return snapshot(j) +} + +func snapshot(j *Job) Job { + jobs.mu.Lock() + defer jobs.mu.Unlock() + c := *j + c.done = nil + return c +} + +// jobByID answers a job by its id, or says it is not known to this process. +func jobByID(id string) (Job, error) { + jobs.mu.Lock() + j, ok := jobs.jobs[id] + jobs.mu.Unlock() + if !ok { + return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs) + } + return snapshot(j), nil +} + +// listJobs answers every job this process knows, newest first. +func listJobs() []Job { + jobs.mu.Lock() + all := make([]*Job, 0, len(jobs.jobs)) + for _, j := range jobs.jobs { + all = append(all, j) + } + jobs.mu.Unlock() + sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) }) + out := make([]Job, 0, len(all)) + for _, j := range all { + out = append(out, snapshot(j)) + } + return out +} + +// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first. +// A finished job that failed is answered as an error, so a failed act is never read as success. +func actAsJob(c Cmd) (Job, error) { + j := awaitJob(startJob(c), JobWait) + if !j.Running && j.Error != "" { + return j, fmt.Errorf("%s (job %s)", j.Error, j.ID) + } + return j, nil +} diff --git a/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go b/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go new file mode 100644 index 0000000..46e1725 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if c.Timeout != JobLimit { + t.Errorf("a job is held to JobLimit, not %s", c.Timeout) + } + if strings.Contains(line, "bad") { + return Result{Status: 2, Stderr: "it broke"} + } + return ok("done") + }) + j, err := actAsJob(Cmd{Name: "good"}) + if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" { + t.Fatalf("%+v %v", j, err) + } + if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") { + t.Fatalf("a failed job: %v", err) + } + got, err := jobByID(j.ID) + if err != nil || got.ID != j.ID { + t.Fatalf("by id: %+v %v", got, err) + } + if _, err := jobByID("nope"); err == nil { + t.Fatal("an unknown job") + } + if len(listJobs()) < 2 { + t.Fatal("listed") + } +} + +func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) { + release := make(chan struct{}) + using(t, func(line string, c Cmd) Result { <-release; return ok("") }) + j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond) + if !j.Running || j.ID == "" { + t.Fatalf("%+v", j) + } + close(release) + time.Sleep(50 * time.Millisecond) + got, _ := jobByID(j.ID) + if got.Running { + t.Fatalf("finished afterwards: %+v", got) + } +} diff --git a/modules/docker-compose/cmd/docker-compose-tools/kit.go b/modules/docker-compose/cmd/docker-compose-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-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/docker-compose/cmd/docker-compose-tools/kit_test.go b/modules/docker-compose/cmd/docker-compose-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-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/docker-compose/cmd/docker-compose-tools/main.go b/modules/docker-compose/cmd/docker-compose-tools/main.go new file mode 100644 index 0000000..d283798 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-tools/main.go @@ -0,0 +1,199 @@ +// The docker-compose module's tools (novox/hq research 027/02, 026/05): the compose projects on this +// machine, their containers, logs and rendered configuration, and bringing one up, down or round +// again by its directory. A Go bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it +// runs as the operator account, which reaches the container runtime through the docker group. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "docker": "the docker module installs the container runtime; this module adds compose to it", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// where is the argument schema every tool that acts on one project takes. +var where = map[string]any{ + "dir": map[string]any{"type": "string", "description": "the project's directory, absolute: where its compose file is"}, + "project": map[string]any{"type": "string", "description": "the project's name, for a project docker already knows (instead of dir)"}, +} + +func with(extra map[string]any) map[string]any { + out := map[string]any{} + for k, v := range where { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out +} + +var servicesArg = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only these services (default all)"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "docker_compose_projects", + Description: "The compose projects docker knows on this machine, running or stopped: name, status, working " + + "directory, compose files, services, and containers running of total. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Projects() }, + }, + { + Name: "docker_compose_ps", + Description: "One project's containers: service, state, health, exit code, image and published ports. (r)", + Input: with(nil), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, false) + if err != nil { + return nil, err + } + return Ps(t) + }, + }, + { + Name: "docker_compose_logs", + Description: "One project's logs, the last lines of each service (default 200, at most 5000), optionally since a " + + "time (\"10m\", \"2026-10-04T12:00:00\"). Cut at 256 KiB. (r)", + Input: with(map[string]any{ + "services": servicesArg, + "tail": map[string]any{"type": "integer", "description": "lines per service (default 200, at most 5000)"}, + "since": map[string]any{"type": "string", "description": "only lines since this: a duration such as 10m or a timestamp"}, + }), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, false) + if err != nil { + return nil, err + } + services, err := optList(args, "services") + if err != nil { + return nil, err + } + n, err := optWhole(args, "tail", 200, 1, 5000) + if err != nil { + return nil, err + } + since, err := optText(args, "since", "") + if err != nil { + return nil, err + } + return Logs(t, services, n, since) + }, + }, + { + Name: "docker_compose_config", + Description: "A project's configuration as compose renders it: files merged, variables filled. Values of " + + "environment variables, build arguments and labels whose names suggest a secret are replaced with " + + "[redacted]. An invalid file is answered as the error compose gives. (r)", + Input: with(nil), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, true) + if err != nil { + return nil, err + } + return Config(t) + }, + }, + { + Name: "docker_compose_up", + Description: "Bring a project up, detached: create and start its containers, building or pulling what is " + + "missing. Answers when finished, or after 18 s with a job to follow with docker_compose_job. (a)", + Input: with(map[string]any{ + "services": servicesArg, + "build": map[string]any{"type": "boolean", "description": "build images before starting (--build)"}, + "pull": map[string]any{"type": "string", "enum": []string{"missing", "always", "never"}, "description": "pull policy (default missing)"}, + }), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, true) + if err != nil { + return nil, err + } + services, err := optList(args, "services") + if err != nil { + return nil, err + } + build, err := optFlag(args, "build", false) + if err != nil { + return nil, err + } + pull, err := optText(args, "pull", "missing") + if err != nil { + return nil, err + } + return Up(t, services, build, pull) + }, + }, + { + Name: "docker_compose_down", + Description: "Stop and remove a project's containers and networks. Its volumes are kept: removing data is not " + + "this tool's. Answers when finished, or with a job to follow. (a)", + Input: with(nil), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, false) + if err != nil { + return nil, err + } + return Down(t) + }, + }, + { + Name: "docker_compose_restart", + Description: "Restart a project's containers, or some of its services. Answers when finished, or with a job to follow. (a)", + Input: with(map[string]any{"services": servicesArg}), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, false) + if err != nil { + return nil, err + } + services, err := optList(args, "services") + if err != nil { + return nil, err + } + return Restart(t, services) + }, + }, + { + Name: "docker_compose_pull", + Description: "Pull a project's images, or some services', without starting anything. Answers when finished, or with a job to follow. (a)", + Input: with(map[string]any{"services": servicesArg}), + Run: func(args map[string]any) (any, error) { + t, err := targetOf(args, true) + if err != nil { + return nil, err + } + services, err := optList(args, "services") + if err != nil { + return nil, err + } + return Pull(t, services) + }, + }, + { + Name: "docker_compose_job", + Description: "A long act this module started (up, down, restart, pull): running or finished, its exit status " + + "and the end of its output. Without job, every act this process knows, newest first. (r)", + Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}}, + Run: func(args map[string]any) (any, error) { + id, err := optText(args, "job", "") + if err != nil { + return nil, err + } + if id == "" { + return map[string]any{"jobs": listJobs()}, nil + } + return jobByID(id) + }, + }, + } +} diff --git a/modules/docker-compose/cmd/docker-compose-tools/manifest_kit_test.go b/modules/docker-compose/cmd/docker-compose-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/docker-compose/cmd/docker-compose-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/docker-compose/go.mod b/modules/docker-compose/go.mod new file mode 100644 index 0000000..f6fd588 --- /dev/null +++ b/modules/docker-compose/go.mod @@ -0,0 +1,5 @@ +module docker-compose + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/docker-compose/go.sum b/modules/docker-compose/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/docker-compose/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/docker-compose/module.json b/modules/docker-compose/module.json new file mode 100644 index 0000000..2f576b4 --- /dev/null +++ b/modules/docker-compose/module.json @@ -0,0 +1,40 @@ +{ + "module": "docker-compose", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "docker_compose_projects", + "docker_compose_ps", + "docker_compose_logs", + "docker_compose_config", + "docker_compose_up", + "docker_compose_down", + "docker_compose_restart", + "docker_compose_pull", + "docker_compose_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "docker-compose" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/docker-compose-tools", + "binary": "docker-compose-tools", + "loads": [ + "docker-compose-tools" + ] + } + ] + } +} diff --git a/modules/flatpak/README.md b/modules/flatpak/README.md new file mode 100644 index 0000000..6b4d64f --- /dev/null +++ b/modules/flatpak/README.md @@ -0,0 +1,80 @@ +# flatpak + +Flatpak applications on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are +modules, on the two workstations only"; to-be 42 phase 2 step 9). + +## Owns + +| what | where | +|---|---| +| flatpak | package `flatpak` (official repositories) | + +**The Flathub remote comes with the package.** The package ships `flathub.flatpakrepo` in +`/usr/share/flatpak/remotes.d`, and flatpak adds every file there to the system installation as a +remote. The module therefore declares no remote file of its own. `flatpak_remotes` checks that the +system installation has Flathub, and says so when it does not. + +The installed applications and runtimes, and their data, are the operator's: found (ADR 0182). + +## Improves + +- **The laptop gains flatpak**, which only the desktop had, with Flathub. +- **Unused runtimes become visible and removable.** On the desktop on 2026-10-04, `/var/lib/flatpak` + held 5.1 GB for three applications. They are Nextcloud on KDE 6.7, Warehouse on GNOME 46, and Plex on + Freedesktop 23.08. Beside them sat the whole Freedesktop 22.08 platform, both of its GL extensions, + and older codec and style extensions that nothing uses. `flatpak_unused` lists them read-only; + `flatpak_remove_unused` lets flatpak remove them. +- **A duplicate remote is named.** The desktop has Flathub configured twice, once in the system + installation and once in the account's, which has nothing installed from it. `flatpak_remotes` + reports it. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account. An act on the system +installation (the default) goes through `sudo -n`; one on the account's own installation does not. + +| tool | what | +|---|---| +| `flatpak_list` (r) | applications: id, name, version, branch, origin, installation, size in bytes | +| `flatpak_runtimes` (r) | runtimes and extensions, the same way | +| `flatpak_remotes` (r) | both installations' remotes, how many refs come from each, findings | +| `flatpak_updates` (r) | refs with a newer commit on their remote (needs the network) | +| `flatpak_unused` (r) | runtimes nothing needs, computed without changing anything (below), with the space each takes | +| `flatpak_disk_usage` (r) | each installation's directory on disk, and the 50 largest refs | +| `flatpak_install` (a) | install from a remote (default `flathub`) into the system or the account installation | +| `flatpak_remove` (a) | uninstall, keeping the application's data unless `delete_data` | +| `flatpak_update` (a) | update one ref, or everything in an installation | +| `flatpak_remove_unused` (a) | `uninstall --unused`: flatpak decides | +| `flatpak_job` (r) | a long act's state and the end of its output | + +**Unused, computed.** `flatpak uninstall --unused` has no dry run: it asks, and answering *yes* removes. +So the read tool works it out from what flatpak already says. A runtime is in use when one of these +holds: + +- an installed application names it as its runtime or SDK; +- it fills an extension point declared in the metadata of something in use, matched by id + (subdirectories included) and accepted version; +- it is pinned. + +The SDK is counted as used to err on the safe side. flatpak's own `--unused` may also remove an +application's SDK. + +**Acts are jobs.** An install or update downloads hundreds of megabytes. Each act runs inside the +tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is answered, and a +failed one as an error. One still running is answered with a job id for `flatpak_job`. + +## What changes when it is assigned + +- **laptop:** `flatpak` is installed, with Flathub as the system remote. Nothing else. +- **desktop:** nothing on disk; the package, installed by hand, becomes the mesh's. + +## The one-off step for the operator (ADR 0182) + +On the desktop, the account's own Flathub remote duplicates the system's and has nothing installed +from it. If it is not wanted: `flatpak remote-delete --user flathub`, once. The mesh did not make it, +so it does not remove it. + +## Leaves as found + +The applications and runtimes, the account installation under `~/.local/share/flatpak`, application +data under `~/.var/app`, and any remote other than the package's Flathub. diff --git a/modules/flatpak/cmd/flatpak-tools/flatpak.go b/modules/flatpak/cmd/flatpak-tools/flatpak.go new file mode 100644 index 0000000..1fdf87c --- /dev/null +++ b/modules/flatpak/cmd/flatpak-tools/flatpak.go @@ -0,0 +1,501 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// refs are an application or runtime id, optionally with arch and branch: org.gimp.GIMP, +// org.freedesktop.Platform/x86_64/23.08, app/org.gimp.GIMP/x86_64/stable. +var refs = regexp.MustCompile(`^((app|runtime)/)?[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+(/[A-Za-z0-9_]*(/[A-Za-z0-9._-]*)?)?$`) + +func checkRef(r string) error { + if !refs.MatchString(r) { + return fmt.Errorf("%q is not a flatpak id or ref", r) + } + return nil +} + +var remoteName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// Ref is one installed application or runtime. +type Ref struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Branch string `json:"branch"` + Arch string `json:"arch"` + Ref string `json:"ref"` + Origin string `json:"origin"` + Installation string `json:"installation"` + Size string `json:"size"` + Bytes int64 `json:"bytes"` +} + +// sizeBytes reads flatpak's human size ("275.8 MB", with a no-break space): decimal units. +func sizeBytes(s string) int64 { + f := strings.Fields(strings.ReplaceAll(s, " ", " ")) + if len(f) == 0 { + return 0 + } + n, err := strconv.ParseFloat(f[0], 64) + if err != nil { + return 0 + } + unit := map[string]float64{"bytes": 1, "byte": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12} + if len(f) > 1 { + if m, ok := unit[f[1]]; ok { + n *= m + } + } + return int64(n) +} + +func installFlag(inst string) []string { + if inst == "" { + return nil + } + return []string{"--" + inst} +} + +// listRefs runs flatpak list for apps or runtimes. +func listRefs(kind, inst string) ([]Ref, error) { + args := append([]string{"list", "--" + kind, "--columns=application,name,version,branch,arch,origin,installation,size,ref"}, installFlag(inst)...) + r, err := call(Cmd{Name: "flatpak", Args: args}) + if err != nil { + return nil, err + } + out := []Ref{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) < 9 { + continue + } + size := strings.ReplaceAll(f[7], " ", " ") + out = append(out, Ref{ID: f[0], Name: f[1], Version: f[2], Branch: f[3], Arch: f[4], Origin: f[5], Installation: f[6], Size: size, Bytes: sizeBytes(size), Ref: f[8]}) + } + return out, nil +} + +// ListAnswer is what flatpak_list and flatpak_runtimes answer. +type ListAnswer struct { + Count int `json:"count"` + Bytes int64 `json:"bytes"` + Refs []Ref `json:"refs"` +} + +// List answers the applications or runtimes. +func List(kind, inst string) (ListAnswer, error) { + got, err := listRefs(kind, inst) + if err != nil { + return ListAnswer{}, err + } + out := ListAnswer{Count: len(got), Refs: got} + for _, r := range got { + out.Bytes += r.Bytes + } + return out, nil +} + +// Remote is one remote of one installation. +type Remote struct { + Name string `json:"name"` + URL string `json:"url"` + Installation string `json:"installation"` + Priority string `json:"priority"` + Disabled bool `json:"disabled"` + Installed int `json:"installed_from"` +} + +// RemotesAnswer is what flatpak_remotes answers. +type RemotesAnswer struct { + Remotes []Remote `json:"remotes"` + Flathub bool `json:"flathub_system"` + Findings []string `json:"findings"` +} + +// Remotes answers both installations' remotes and what is wrong with them. +func Remotes() (RemotesAnswer, error) { + r, err := call(Cmd{Name: "flatpak", Args: []string{"remotes", "--show-disabled", "--columns=name,url,options,priority"}}) + if err != nil { + return RemotesAnswer{}, err + } + all, err := listRefs("app", "") + if err != nil { + return RemotesAnswer{}, err + } + rt, err := listRefs("runtime", "") + if err != nil { + return RemotesAnswer{}, err + } + from := map[string]int{} + for _, x := range append(all, rt...) { + from[x.Origin+"\x00"+x.Installation]++ + } + out := RemotesAnswer{Remotes: []Remote{}, Findings: []string{}} + byURL := map[string][]string{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) < 4 { + continue + } + inst := "system" + disabled := false + for _, o := range strings.Split(f[2], ",") { + switch strings.TrimSpace(o) { + case "user": + inst = "user" + case "disabled": + disabled = true + } + if strings.HasPrefix(strings.TrimSpace(o), "system") { + inst = strings.TrimSpace(o) + } + } + rem := Remote{Name: f[0], URL: f[1], Installation: inst, Priority: f[3], Disabled: disabled, Installed: from[f[0]+"\x00"+inst]} + out.Remotes = append(out.Remotes, rem) + byURL[strings.TrimRight(rem.URL, "/")] = append(byURL[strings.TrimRight(rem.URL, "/")], rem.Name+" ("+inst+")") + if inst == "system" && rem.Name == "flathub" && !disabled { + out.Flathub = true + } + if rem.Installed == 0 { + out.Findings = append(out.Findings, fmt.Sprintf("remote %s in the %s installation has nothing installed from it", rem.Name, inst)) + } + } + if !out.Flathub { + out.Findings = append(out.Findings, "the system installation has no enabled flathub remote: the flatpak package ships one in /usr/share/flatpak/remotes.d, so it was removed or disabled by hand") + } + for url, names := range byURL { + if len(names) > 1 { + sort.Strings(names) + out.Findings = append(out.Findings, fmt.Sprintf("%s is configured %d times: %s", url, len(names), strings.Join(names, ", "))) + } + } + sort.Strings(out.Findings) + return out, nil +} + +// Updates answers what an update would change. +func Updates(inst string) (map[string]any, error) { + args := append([]string{"remote-ls", "--updates", "--columns=application,branch,version,origin,ref"}, installFlag(inst)...) + r, err := call(Cmd{Name: "flatpak", Args: args}) + if err != nil { + return nil, err + } + out := []map[string]string{} + for _, l := range lines(r.Stdout) { + f := strings.Split(l, "\t") + if len(f) >= 5 { + out = append(out, map[string]string{"id": f[0], "branch": f[1], "version": f[2], "origin": f[3], "ref": f[4]}) + } + } + return map[string]any{"count": len(out), "updates": out}, nil +} + +// Extension is one extension point a ref's metadata declares. +type Extension struct { + ID string + Versions []string + Subdirs bool +} + +// extensionsOf reads [Extension …] groups from a ref's metadata, with the versions each accepts: +// versions, else version, else the branch of the ref declaring it. +func extensionsOf(metadata, branch string) []Extension { + out := []Extension{} + var cur *Extension + flush := func() { + if cur != nil { + if len(cur.Versions) == 0 { + cur.Versions = []string{branch} + } + out = append(out, *cur) + } + cur = nil + } + var version, versions string + for _, l := range strings.Split(metadata, "\n") { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "[") { + if cur != nil { + cur.Versions = pickVersions(versions, version) + } + flush() + version, versions = "", "" + if strings.HasPrefix(l, "[Extension ") && strings.HasSuffix(l, "]") { + cur = &Extension{ID: strings.TrimSuffix(strings.TrimPrefix(l, "[Extension "), "]")} + } + continue + } + if cur == nil { + continue + } + k, v, found := strings.Cut(l, "=") + if !found { + continue + } + switch strings.TrimSpace(k) { + case "version": + version = strings.TrimSpace(v) + case "versions": + versions = strings.TrimSpace(v) + case "subdirectories": + cur.Subdirs = strings.TrimSpace(v) == "true" + } + } + if cur != nil { + cur.Versions = pickVersions(versions, version) + } + flush() + return out +} + +func pickVersions(versions, version string) []string { + if versions != "" { + out := []string{} + for _, v := range strings.Split(versions, ";") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out + } + if version != "" { + return []string{version} + } + return nil +} + +// fills says whether a runtime fills an extension point. +func (e Extension) fills(r Ref) bool { + if !(r.ID == e.ID || (e.Subdirs && strings.HasPrefix(r.ID, e.ID+"."))) { + return false + } + for _, v := range e.Versions { + if v == r.Branch { + return true + } + } + return false +} + +// UnusedAnswer is what flatpak_unused answers. +type UnusedAnswer struct { + Count int `json:"count"` + Bytes int64 `json:"bytes"` + Unused []Ref `json:"unused"` + Pinned []string `json:"pinned"` + Note string `json:"note"` +} + +func info(inst string, extra ...string) (string, error) { + r, err := call(Cmd{Name: "flatpak", Args: append(append([]string{"info"}, installFlag(inst)...), extra...)}) + return strings.TrimSpace(r.Stdout), err +} + +// Unused computes what no installed application needs. +func Unused() (UnusedAnswer, error) { + apps, err := listRefs("app", "") + if err != nil { + return UnusedAnswer{}, err + } + runtimes, err := listRefs("runtime", "") + if err != nil { + return UnusedAnswer{}, err + } + key := func(inst, ref string) string { + return inst + "\x00" + strings.TrimPrefix(strings.TrimPrefix(ref, "runtime/"), "app/") + } + installed := map[string]Ref{} + for _, r := range runtimes { + installed[key(r.Installation, r.Ref)] = r + } + used := map[string]bool{} + type item struct { + inst, ref, branch string + } + queue := []item{} + // A user installation's application may use a system runtime; a system one only system runtimes. + mark := func(inst, ref string) { + for _, where := range []string{inst, "system"} { + k := key(where, ref) + if r, ok := installed[k]; ok && !used[k] { + used[k] = true + queue = append(queue, item{where, r.Ref, r.Branch}) + return + } + } + } + for _, a := range apps { + for _, flag := range []string{"--show-runtime", "--show-sdk"} { + ref, err := info(a.Installation, flag, a.Ref) + if err != nil { + return UnusedAnswer{}, err + } + if ref != "" { + mark(a.Installation, ref) + } + } + queue = append(queue, item{a.Installation, a.Ref, a.Branch}) + } + pinned := []string{} + for _, inst := range []string{"system", "user"} { + r := run(Cmd{Name: "flatpak", Args: []string{"pin", "--" + inst}}) + if r.Status != 0 || r.Error != "" { + continue + } + for _, l := range lines(r.Stdout) { + p := strings.TrimSpace(l) + if strings.HasPrefix(p, "runtime/") { + pinned = append(pinned, p) + mark(inst, p) + } + } + } + for len(queue) > 0 { + it := queue[0] + queue = queue[1:] + meta, err := info(it.inst, "--show-metadata", it.ref) + if err != nil { + return UnusedAnswer{}, err + } + for _, e := range extensionsOf(meta, it.branch) { + for k, r := range installed { + if !used[k] && e.fills(r) && (r.Installation == it.inst || r.Installation == "system") { + used[k] = true + queue = append(queue, item{r.Installation, r.Ref, r.Branch}) + } + } + } + } + out := UnusedAnswer{Unused: []Ref{}, Pinned: pinned, + Note: "Computed without changing anything. flatpak_remove_unused lets flatpak decide, which may differ in detail (a locale or debug extension listed here by nothing)."} + for k, r := range installed { + if !used[k] { + out.Unused = append(out.Unused, r) + out.Bytes += r.Bytes + } + } + sort.Slice(out.Unused, func(i, k int) bool { return out.Unused[i].Ref < out.Unused[k].Ref }) + out.Count = len(out.Unused) + return out, nil +} + +// Where each installation lives. +var installDirs = map[string]string{"system": "/var/lib/flatpak", "user": ".local/share/flatpak"} + +// DiskAnswer is what flatpak_disk_usage answers. +type DiskAnswer struct { + Installations map[string]int64 `json:"installation_bytes"` + Largest []Ref `json:"largest"` + Note string `json:"note,omitempty"` +} + +// DiskUsage measures each installation's directory and lists the largest refs. +func DiskUsage() (DiskAnswer, error) { + out := DiskAnswer{Installations: map[string]int64{}, Largest: []Ref{}} + for inst, dir := range installDirs { + if !filepath.IsAbs(dir) { + dir = filepath.Join(accountHome(), dir) + } + // du exits 1 when a file is unreadable and still prints the total of what it could read. + r := run(Cmd{Name: "du", Args: []string{"-sb", dir}}) + if r.Error != "" { + return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r) + } + f := strings.Fields(r.Stdout) + if len(f) == 0 { + if strings.Contains(r.Stderr, "No such file") { + out.Installations[inst] = 0 + continue + } + return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r) + } + n, _ := strconv.ParseInt(f[0], 10, 64) + out.Installations[inst] = n + if r.Status != 0 { + out.Note = "some files were unreadable to the account, so a total is a lower bound" + } + } + apps, err := listRefs("app", "") + if err != nil { + return DiskAnswer{}, err + } + rts, err := listRefs("runtime", "") + if err != nil { + return DiskAnswer{}, err + } + all := append(apps, rts...) + sort.Slice(all, func(i, k int) bool { return all[i].Bytes > all[k].Bytes }) + if len(all) > 50 { + all = all[:50] + } + out.Largest = all + return out, nil +} + +func accountHome() string { + return homeFrom(getenv) +} + +// ActAnswer is what an act answers. +type ActAnswer struct { + Act string `json:"act"` + Ref string `json:"ref,omitempty"` + Installation string `json:"installation"` + Job Job `json:"job"` +} + +// act runs flatpak as a job; the system installation needs root, the account's does not. +func act(verb, ref, inst string, args ...string) (ActAnswer, error) { + c := Cmd{Name: "flatpak", Args: append([]string{verb, "--" + inst, "--noninteractive", "-y"}, args...), Root: inst == "system"} + j, err := actAsJob(c) + if err != nil { + return ActAnswer{}, err + } + return ActAnswer{Act: verb, Ref: ref, Installation: inst, Job: j}, nil +} + +// Install installs a ref from a remote. +func Install(ref, remote, inst string) (ActAnswer, error) { + if !remoteName.MatchString(remote) { + return ActAnswer{}, fmt.Errorf("%q is not a remote's name", remote) + } + return act("install", ref, inst, remote, ref) +} + +// Remove uninstalls a ref. +func Remove(ref, inst string, deleteData bool) (ActAnswer, error) { + if deleteData { + return act("uninstall", ref, inst, "--delete-data", ref) + } + return act("uninstall", ref, inst, ref) +} + +// Update updates one ref, or everything. +func Update(ref, inst string) (ActAnswer, error) { + if ref == "" { + return act("update", "", inst) + } + return act("update", ref, inst, ref) +} + +// RemoveUnused lets flatpak uninstall what it finds unused. +func RemoveUnused(inst string) (ActAnswer, error) { + return act("uninstall", "", inst, "--unused") +} + +// getenv and homeFrom read the account's home the way the runtime gives it. +var getenv = func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + +func homeFrom(env func(string) string) string { + if h := env("MESH_OPERATOR_HOME"); h != "" { + return h + } + return env("HOME") +} diff --git a/modules/flatpak/cmd/flatpak-tools/flatpak_test.go b/modules/flatpak/cmd/flatpak-tools/flatpak_test.go new file mode 100644 index 0000000..22e7bf2 --- /dev/null +++ b/modules/flatpak/cmd/flatpak-tools/flatpak_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsFlatpaksPackageAndFlathubComesWithIt(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "flatpak") + if got := strings.Join(m.packages(), ","); got != "flatpak" { + t.Errorf("packages %s", got) + } + // The package ships flathub in /usr/share/flatpak/remotes.d: the module declares no remote file. + if len(m.Resources) != 1 { + t.Errorf("one resource: %v", m.Resources) + } +} + +const nb = " " + +// The desktop's installation on 2026-10-04, as flatpak list prints it. +var apps = "com.nextcloud.desktopclient.nextcloud\tNextcloud Desktop\t3.14\tstable\tx86_64\tflathub\tsystem\t275.8" + nb + "MB\tcom.nextcloud.desktopclient.nextcloud/x86_64/stable\n" + + "tv.plex.PlexDesktop\tPlex\t1.9\tstable\tx86_64\tflathub\tsystem\t368.0" + nb + "MB\ttv.plex.PlexDesktop/x86_64/stable\n" + +var runtimes = strings.Join([]string{ + "org.freedesktop.Platform\tFreedesktop Platform\t22.08.1\t22.08\tx86_64\tflathub\tsystem\t576.7" + nb + "MB\torg.freedesktop.Platform/x86_64/22.08", + "org.freedesktop.Platform\tFreedesktop Platform\t23.08.1\t23.08\tx86_64\tflathub\tsystem\t598.3" + nb + "MB\torg.freedesktop.Platform/x86_64/23.08", + "org.freedesktop.Platform.GL.default\tMesa\t\t22.08\tx86_64\tflathub\tsystem\t442.1" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/22.08", + "org.freedesktop.Platform.GL.default\tMesa\t\t23.08\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08", + "org.freedesktop.Platform.GL.default\tMesa\t\t23.08-extra\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08-extra", + "org.freedesktop.Platform.openh264\topenh264\t\t2.2.0\tx86_64\tflathub\tsystem\t790.0" + nb + "kB\torg.freedesktop.Platform.openh264/x86_64/2.2.0", + "org.freedesktop.Sdk\tSdk\t\t23.08\tx86_64\tflathub\tsystem\t1.6" + nb + "GB\torg.freedesktop.Sdk/x86_64/23.08", + "org.kde.Platform\tKDE\t\t6.7\tx86_64\tflathub\tsystem\t931.4" + nb + "MB\torg.kde.Platform/x86_64/6.7", + "org.kde.KStyle.Adwaita\tAdwaita\t\t5.15-21.08\tx86_64\tflathub\tsystem\t16.3" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/5.15-21.08", + "org.kde.KStyle.Adwaita\tAdwaita\t\t6.7\tx86_64\tflathub\tsystem\t20.6" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/6.7", +}, "\n") + "\n" + +const platformMeta = `[Runtime] +name=org.freedesktop.Platform + +[Extension org.freedesktop.Platform.GL] +versions = 23.08;23.08-extra;1.4 +version = 1.4 +directory = lib/x86_64-linux-gnu/GL +subdirectories = true + +[Extension org.freedesktop.Platform.openh264] +directory = lib/openh264 +version = 2.2.0 + +[Extension org.freedesktop.Platform.Timezones] +directory = share/zoneinfo +` + +const kdeMeta = `[Runtime] +name=org.kde.Platform + +[Extension org.kde.KStyle] +directory = lib/plugins/styles +subdirectories = true +version = 6.7 + +[Extension org.freedesktop.Platform.GL] +versions = 23.08;1.4 +subdirectories = true +` + +func theDesktop(t *testing.T) *fake { + return using(t, func(line string, c Cmd) Result { + switch { + case strings.HasPrefix(line, "flatpak list --app"): + return ok(apps) + case strings.HasPrefix(line, "flatpak list --runtime"): + return ok(runtimes) + case strings.Contains(line, "--show-runtime com.nextcloud"): + return ok("org.kde.Platform/x86_64/6.7\n") + case strings.Contains(line, "--show-runtime tv.plex"): + return ok("org.freedesktop.Platform/x86_64/23.08\n") + case strings.Contains(line, "--show-sdk tv.plex"): + return ok("org.freedesktop.Sdk/x86_64/23.08\n") + case strings.Contains(line, "--show-sdk"): + return ok("org.kde.Sdk/x86_64/6.7\n") + case strings.Contains(line, "--show-metadata org.freedesktop.Platform/x86_64/23.08"): + return ok(platformMeta) + case strings.Contains(line, "--show-metadata org.kde.Platform"): + return ok(kdeMeta) + case strings.Contains(line, "--show-metadata"): + return ok("[Application]\nname=x\n") + case strings.HasPrefix(line, "flatpak pin"): + return ok("") + case strings.HasPrefix(line, "flatpak remotes"): + return ok("flathub\thttps://dl.flathub.org/repo/\tsystem\t1\nflathub\thttps://dl.flathub.org/repo/\tuser\t1\n") + } + return Result{Status: 9, Stderr: "unexpected " + line} + }) +} + +func TestUnusedIsWhatNoApplicationNeedsDirectlyOrThroughAnExtensionPoint(t *testing.T) { + f := theDesktop(t) + got, err := Unused() + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, r := range got.Unused { + names = append(names, r.Ref) + } + want := "org.freedesktop.Platform.GL.default/x86_64/22.08,org.freedesktop.Platform/x86_64/22.08,org.kde.KStyle.Adwaita/x86_64/5.15-21.08" + if strings.Join(names, ",") != want { + t.Errorf("unused\n%s\nwant\n%s", strings.Join(names, ","), want) + } + if got.Bytes != 576700000+442100000+16300000 { + t.Errorf("bytes %d", got.Bytes) + } + for _, l := range f.lines() { + if strings.Contains(l, "uninstall") || strings.HasPrefix(l, "sudo") { + t.Errorf("unused only reads: %s", l) + } + } +} + +func TestAnExtensionPointAcceptsItsVersionsOrTheDeclaringBranch(t *testing.T) { + ext := extensionsOf(platformMeta, "23.08") + if len(ext) != 3 { + t.Fatalf("%+v", ext) + } + gl, h264, tz := ext[0], ext[1], ext[2] + if !gl.Subdirs || strings.Join(gl.Versions, ";") != "23.08;23.08-extra;1.4" { + t.Errorf("versions win over version: %+v", gl) + } + if h264.Subdirs || strings.Join(h264.Versions, ";") != "2.2.0" { + t.Errorf("%+v", h264) + } + if strings.Join(tz.Versions, ";") != "23.08" { + t.Errorf("no version is the declaring branch: %+v", tz) + } + if !gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "23.08-extra"}) || gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "22.08"}) { + t.Error("a subdirectory extension, by branch") + } + if h264.fills(Ref{ID: "org.freedesktop.Platform.openh264.x", Branch: "2.2.0"}) { + t.Error("without subdirectories only the id itself") + } +} + +func TestRemotesFindTheDuplicateAndTheEmptyOne(t *testing.T) { + theDesktop(t) + got, err := Remotes() + if err != nil || len(got.Remotes) != 2 || !got.Flathub { + t.Fatalf("%+v %v", got, err) + } + if got.Remotes[0].Installed != 12 || got.Remotes[1].Installed != 0 { + t.Errorf("%+v", got.Remotes) + } + all := strings.Join(got.Findings, ";") + if !strings.Contains(all, "flathub in the user installation has nothing installed") || !strings.Contains(all, "configured 2 times") { + t.Errorf("%s", all) + } +} + +func TestListReadsSizesAsBytes(t *testing.T) { + theDesktop(t) + got, err := List("app", "") + if err != nil || got.Count != 2 || got.Refs[0].Bytes != 275800000 || got.Refs[0].Size != "275.8 MB" || got.Bytes != 643800000 { + t.Fatalf("%+v %v", got, err) + } + for in, want := range map[string]int64{"1.6" + nb + "GB": 1600000000, "790.0 kB": 790000, "12 bytes": 12, "": 0} { + if b := sizeBytes(in); b != want { + t.Errorf("%q: %d", in, b) + } + } +} + +func TestActsOnTheSystemInstallationEscalateAndTheAccountsDoNot(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok("done") }) + if got, err := Install("org.gimp.GIMP", "flathub", "system"); err != nil || got.Job.Running { + t.Fatalf("%+v %v", got, err) + } + if _, err := Install("org.gimp.GIMP", "flathub", "user"); err != nil { + t.Fatal(err) + } + if _, err := Remove("org.gimp.GIMP", "system", true); err != nil { + t.Fatal(err) + } + if _, err := Update("", "user"); err != nil { + t.Fatal(err) + } + if _, err := RemoveUnused("system"); err != nil { + t.Fatal(err) + } + want := []string{ + "sudo -n flatpak install --system --noninteractive -y flathub org.gimp.GIMP", + "flatpak install --user --noninteractive -y flathub org.gimp.GIMP", + "sudo -n flatpak uninstall --system --noninteractive -y --delete-data org.gimp.GIMP", + "flatpak update --user --noninteractive -y", + "sudo -n flatpak uninstall --system --noninteractive -y --unused", + } + if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") { + t.Errorf("asked\n%s", got) + } + if _, err := Install("org.gimp.GIMP", "--from=x", "system"); err == nil { + t.Error("an option as a remote") + } + for _, bad := range []string{"--assumeyes", "gimp", "org.gimp.GIMP; rm", "org/../x"} { + if checkRef(bad) == nil { + t.Errorf("%q accepted as a ref", bad) + } + } + for _, good := range []string{"org.gimp.GIMP", "org.freedesktop.Platform/x86_64/23.08", "app/org.gimp.GIMP/x86_64/stable", "org.freedesktop.Platform.GL.default//23.08-extra"} { + if err := checkRef(good); err != nil { + t.Errorf("%v", err) + } + } +} + +func TestAFailedInstallIsAnErrorAndAMissingFlatpakIsSaid(t *testing.T) { + using(t, func(string, Cmd) Result { + return Result{Status: 1, Stderr: "error: Nothing matches org.nope.Nope in remote flathub"} + }) + if _, err := Install("org.nope.Nope", "flathub", "user"); err == nil || !strings.Contains(err.Error(), "Nothing matches") { + t.Fatalf("%v", err) + } + using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} }) + if _, err := List("app", ""); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("%v", err) + } +} + +func TestDiskUsageReadsEachInstallationAndToleratesAnUnreadableFile(t *testing.T) { + t.Setenv("MESH_OPERATOR_HOME", "/home/op") + using(t, func(line string, c Cmd) Result { + switch { + case line == "du -sb /var/lib/flatpak": + return Result{Status: 1, Stdout: "5094728394\t/var/lib/flatpak\n", Stderr: "du: cannot read directory 'x': Permission denied"} + case line == "du -sb /home/op/.local/share/flatpak": + return Result{Status: 1, Stderr: "du: cannot access '/home/op/.local/share/flatpak': No such file or directory"} + case strings.Contains(line, "--app"): + return ok(apps) + } + return ok(runtimes) + }) + got, err := DiskUsage() + if err != nil || got.Installations["system"] != 5094728394 || got.Installations["user"] != 0 || got.Note == "" { + t.Fatalf("%+v %v", got, err) + } + if got.Largest[0].ID != "org.freedesktop.Sdk" { + t.Errorf("largest first: %+v", got.Largest[0]) + } +} diff --git a/modules/flatpak/cmd/flatpak-tools/jobs.go b/modules/flatpak/cmd/flatpak-tools/jobs.go new file mode 100644 index 0000000..3885479 --- /dev/null +++ b/modules/flatpak/cmd/flatpak-tools/jobs.go @@ -0,0 +1,151 @@ +package main + +// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose): +// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an +// act is started as a job inside this process, waited on for a while, and answered either finished +// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the +// runtime restarts the bundle, a running job is cut off, and its id is then unknown. + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id. +const ( + JobLimit = 15 * time.Minute + JobWait = 18 * time.Second + keptJobs = 50 +) + +// Job is one long act, as its tool answers it. +type Job struct { + ID string `json:"job"` + Command string `json:"command"` + Started time.Time `json:"started"` + Finished *time.Time `json:"finished,omitempty"` + Running bool `json:"running"` + Status *int `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Output string `json:"output,omitempty"` + Truncated bool `json:"truncated,omitempty"` + done chan struct{} +} + +type jobBook struct { + mu sync.Mutex + seq int + jobs map[string]*Job +} + +var jobs = &jobBook{jobs: map[string]*Job{}} + +// startJob runs c in the background, held to JobLimit. +func startJob(c Cmd) *Job { + c.Timeout = JobLimit + name, args := argv(c) + jobs.mu.Lock() + jobs.seq++ + j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")), + Started: time.Now().UTC(), Running: true, done: make(chan struct{})} + jobs.jobs[j.ID] = j + jobs.forgetOldest() + jobs.mu.Unlock() + go func() { + r := run(c) + var err error + if r.Status != 0 || r.Error != "" { + err = failure(c, r) + } + jobs.mu.Lock() + now := time.Now().UTC() + j.Finished, j.Running = &now, false + status := r.Status + j.Status = &status + if err != nil { + j.Error = err.Error() + } + j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10) + j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10 + jobs.mu.Unlock() + close(j.done) + }() + return j +} + +// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held. +func (b *jobBook) forgetOldest() { + if len(b.jobs) <= keptJobs { + return + } + all := make([]*Job, 0, len(b.jobs)) + for _, j := range b.jobs { + all = append(all, j) + } + sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) }) + for _, j := range all { + if len(b.jobs) <= keptJobs { + return + } + if !j.Running { + delete(b.jobs, j.ID) + } + } +} + +// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands. +func awaitJob(j *Job, d time.Duration) Job { + select { + case <-j.done: + case <-time.After(d): + } + return snapshot(j) +} + +func snapshot(j *Job) Job { + jobs.mu.Lock() + defer jobs.mu.Unlock() + c := *j + c.done = nil + return c +} + +// jobByID answers a job by its id, or says it is not known to this process. +func jobByID(id string) (Job, error) { + jobs.mu.Lock() + j, ok := jobs.jobs[id] + jobs.mu.Unlock() + if !ok { + return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs) + } + return snapshot(j), nil +} + +// listJobs answers every job this process knows, newest first. +func listJobs() []Job { + jobs.mu.Lock() + all := make([]*Job, 0, len(jobs.jobs)) + for _, j := range jobs.jobs { + all = append(all, j) + } + jobs.mu.Unlock() + sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) }) + out := make([]Job, 0, len(all)) + for _, j := range all { + out = append(out, snapshot(j)) + } + return out +} + +// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first. +// A finished job that failed is answered as an error, so a failed act is never read as success. +func actAsJob(c Cmd) (Job, error) { + j := awaitJob(startJob(c), JobWait) + if !j.Running && j.Error != "" { + return j, fmt.Errorf("%s (job %s)", j.Error, j.ID) + } + return j, nil +} diff --git a/modules/flatpak/cmd/flatpak-tools/jobs_test.go b/modules/flatpak/cmd/flatpak-tools/jobs_test.go new file mode 100644 index 0000000..46e1725 --- /dev/null +++ b/modules/flatpak/cmd/flatpak-tools/jobs_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if c.Timeout != JobLimit { + t.Errorf("a job is held to JobLimit, not %s", c.Timeout) + } + if strings.Contains(line, "bad") { + return Result{Status: 2, Stderr: "it broke"} + } + return ok("done") + }) + j, err := actAsJob(Cmd{Name: "good"}) + if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" { + t.Fatalf("%+v %v", j, err) + } + if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") { + t.Fatalf("a failed job: %v", err) + } + got, err := jobByID(j.ID) + if err != nil || got.ID != j.ID { + t.Fatalf("by id: %+v %v", got, err) + } + if _, err := jobByID("nope"); err == nil { + t.Fatal("an unknown job") + } + if len(listJobs()) < 2 { + t.Fatal("listed") + } +} + +func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) { + release := make(chan struct{}) + using(t, func(line string, c Cmd) Result { <-release; return ok("") }) + j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond) + if !j.Running || j.ID == "" { + t.Fatalf("%+v", j) + } + close(release) + time.Sleep(50 * time.Millisecond) + got, _ := jobByID(j.ID) + if got.Running { + t.Fatalf("finished afterwards: %+v", got) + } +} diff --git a/modules/flatpak/cmd/flatpak-tools/kit.go b/modules/flatpak/cmd/flatpak-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/flatpak/cmd/flatpak-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/flatpak/cmd/flatpak-tools/kit_test.go b/modules/flatpak/cmd/flatpak-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/flatpak/cmd/flatpak-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/flatpak/cmd/flatpak-tools/main.go b/modules/flatpak/cmd/flatpak-tools/main.go new file mode 100644 index 0000000..b5278dd --- /dev/null +++ b/modules/flatpak/cmd/flatpak-tools/main.go @@ -0,0 +1,213 @@ +// The flatpak module's tools (novox/hq research 027/02, 026/05): the applications and runtimes in +// both installations, the remotes, pending updates, what nothing uses any more and the space it all +// takes; and installing, removing and updating. A Go bundle the node's runtime launches over stdio +// (ADR 0188, ADR 0193); it runs as the operator account. An act on the system installation goes +// through `sudo -n`; one on the account's own installation does not. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "flatpak": "the flatpak package, which this module installs", + "du": "the coreutils package", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var installationArg = map[string]any{"type": "string", "enum": []string{"system", "user"}, + "description": "the system installation (default), or the account's own"} + +func installationOf(args map[string]any, def string) (string, error) { + i, err := optText(args, "installation", def) + if err != nil { + return "", err + } + if i == "" { + return "", nil + } + return i, oneOf("installation", i, "system", "user") +} + +func refArg(args map[string]any, key string) (string, error) { + r, err := text(args, key) + if err != nil { + return "", err + } + return r, checkRef(r) +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "flatpak_list", + Description: "The installed applications, in both installations or one: id, name, version, branch, origin, " + + "installation and size. (r)", + Input: map[string]any{"installation": installationArg}, + Run: func(args map[string]any) (any, error) { + inst, err := installationOf(args, "") + if err != nil { + return nil, err + } + return List("app", inst) + }, + }, + { + Name: "flatpak_runtimes", + Description: "The installed runtimes and extensions, in both installations or one: id, branch, origin, installation and size. (r)", + Input: map[string]any{"installation": installationArg}, + Run: func(args map[string]any) (any, error) { + inst, err := installationOf(args, "") + if err != nil { + return nil, err + } + return List("runtime", inst) + }, + }, + { + Name: "flatpak_remotes", + Description: "The remotes of both installations, with how many installed refs come from each, and findings: " + + "Flathub missing from the system installation, the same remote in both installations, a remote nothing " + + "is installed from. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Remotes() }, + }, + { + Name: "flatpak_updates", + Description: "What an update would change: each ref with a newer commit on its remote. Asks the remotes, so it needs the network. (r)", + Input: map[string]any{"installation": installationArg}, + Run: func(args map[string]any) (any, error) { + inst, err := installationOf(args, "") + if err != nil { + return nil, err + } + return Updates(inst) + }, + }, + { + Name: "flatpak_unused", + Description: "The runtimes and extensions no installed application needs any more, computed without changing " + + "anything: a runtime is used when an application names it as its runtime or SDK, when it fills an " + + "extension point of something used, or when it is pinned. With the space each takes. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Unused() }, + }, + { + Name: "flatpak_disk_usage", + Description: "The space flatpak takes: each installation's directory on disk, and the applications and " + + "runtimes by size, largest first. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return DiskUsage() }, + }, + { + Name: "flatpak_install", + Description: "Install an application or runtime from a remote (default flathub), in the system installation " + + "(default) or the account's. Answers when finished, or after 18 s with a job to follow with flatpak_job. (a)", + Input: map[string]any{ + "ref": map[string]any{"type": "string", "description": "an application id such as org.gimp.GIMP, or a full ref"}, + "remote": map[string]any{"type": "string", "description": "the remote (default flathub)"}, + "installation": installationArg, + }, + Run: func(args map[string]any) (any, error) { + ref, err := refArg(args, "ref") + if err != nil { + return nil, err + } + remote, err := optText(args, "remote", "flathub") + if err != nil { + return nil, err + } + inst, err := installationOf(args, "system") + if err != nil { + return nil, err + } + return Install(ref, remote, inst) + }, + }, + { + Name: "flatpak_remove", + Description: "Uninstall an application or runtime, keeping its data unless delete_data is set. Answers when finished, or with a job to follow. (a)", + Input: map[string]any{ + "ref": map[string]any{"type": "string", "description": "the application id or ref"}, + "installation": installationArg, + "delete_data": map[string]any{"type": "boolean", "description": "remove the application's data in the home too"}, + }, + Run: func(args map[string]any) (any, error) { + ref, err := refArg(args, "ref") + if err != nil { + return nil, err + } + inst, err := installationOf(args, "system") + if err != nil { + return nil, err + } + del, err := optFlag(args, "delete_data", false) + if err != nil { + return nil, err + } + return Remove(ref, inst, del) + }, + }, + { + Name: "flatpak_update", + Description: "Update one ref, or everything in an installation when no ref is given. Answers when finished, or with a job to follow. (a)", + Input: map[string]any{ + "ref": map[string]any{"type": "string", "description": "the application id or ref (default all)"}, + "installation": installationArg, + }, + Run: func(args map[string]any) (any, error) { + ref, err := optText(args, "ref", "") + if err != nil { + return nil, err + } + if ref != "" { + if err := checkRef(ref); err != nil { + return nil, err + } + } + inst, err := installationOf(args, "system") + if err != nil { + return nil, err + } + return Update(ref, inst) + }, + }, + { + Name: "flatpak_remove_unused", + Description: "Uninstall what flatpak itself finds unused in one installation (system by default). " + + "flatpak_unused shows what that is beforehand. Answers when finished, or with a job to follow. (a)", + Input: map[string]any{"installation": installationArg}, + Run: func(args map[string]any) (any, error) { + inst, err := installationOf(args, "system") + if err != nil { + return nil, err + } + return RemoveUnused(inst) + }, + }, + { + Name: "flatpak_job", + Description: "A long act this module started: running or finished, its exit status and the end of its output. Without job, every act this process knows. (r)", + Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}}, + Run: func(args map[string]any) (any, error) { + id, err := optText(args, "job", "") + if err != nil { + return nil, err + } + if id == "" { + return map[string]any{"jobs": listJobs()}, nil + } + return jobByID(id) + }, + }, + } +} diff --git a/modules/flatpak/cmd/flatpak-tools/manifest_kit_test.go b/modules/flatpak/cmd/flatpak-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/flatpak/cmd/flatpak-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/flatpak/go.mod b/modules/flatpak/go.mod new file mode 100644 index 0000000..7f34546 --- /dev/null +++ b/modules/flatpak/go.mod @@ -0,0 +1,5 @@ +module flatpak + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/flatpak/go.sum b/modules/flatpak/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/flatpak/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/flatpak/module.json b/modules/flatpak/module.json new file mode 100644 index 0000000..76d4721 --- /dev/null +++ b/modules/flatpak/module.json @@ -0,0 +1,42 @@ +{ + "module": "flatpak", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "flatpak_list", + "flatpak_runtimes", + "flatpak_remotes", + "flatpak_updates", + "flatpak_unused", + "flatpak_disk_usage", + "flatpak_install", + "flatpak_remove", + "flatpak_update", + "flatpak_remove_unused", + "flatpak_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "flatpak" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/flatpak-tools", + "binary": "flatpak-tools", + "loads": [ + "flatpak-tools" + ] + } + ] + } +} 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" + ] + } + ] + } +} diff --git a/modules/snapd/README.md b/modules/snapd/README.md new file mode 100644 index 0000000..c6c6345 --- /dev/null +++ b/modules/snapd/README.md @@ -0,0 +1,71 @@ +# snapd + +Snaps on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are modules, on the +two workstations only"; to-be 42 phase 2 step 9). + +## Status: tools only, the package blocked + +**snapd is not in the distribution's official repositories.** It is a user-repository (AUR) package: +on the desktop it is installed as a foreign package, and `pacman -Si snapd` finds nothing. The host's +`package` shape installs from the official repositories only, so this module cannot declare it. + +Neither form of ADR 0205 fits either. snapd is a daemon in compiled code with setuid helpers, a socket, +services and a system mount, so it is not a pinned archive of plain files. The way out is research 027 +question 1, option P2: the build machine builds user-repository packages into a package repository the +mesh serves. Until that exists: + +- **The module declares no resources.** It does not even declare the units snapd brings + (`snapd.socket`, `snapd.apparmor.service`). On a workstation without the package, the laptop today, + those units do not exist, and the host would fail the module there. A declaration that cannot hold + on every machine the module is assigned to is not written. +- **The tools are written and work wherever snapd is installed.** Where it is not, every tool says + that, rather than answering an empty list. + +When the repository exists, the module gains, in one change: + +1. the package `snapd`; +2. `snapd.socket` enabled and running; +3. `snapd.apparmor.service` enabled only if the kernel runs AppArmor (below); +4. its tests. + +## Improves (once it owns the package) + +- **The disabled revisions become visible.** snapd keeps old revisions for rollback. On the desktop on + 2026-10-04 that was 1.7 GB of snaps, of which about 0.7 GB were disabled revisions: the previous + `code`, `core18`, `core20` and `snapd`. `snapd_disk_usage` answers it. +- **A unit that does nothing is named.** On the desktop `snapd.apparmor.service` is enabled, but the + kernel's security modules are `capability,landlock,lockdown,yama,bpf`. There is no AppArmor, so the + profiles it would load are enforced by nothing, and strict snaps run unconfined. `snapd_status` says + so. Turning AppArmor on is a kernel command-line change, which is the `kernel` module's, and is the + operator's choice. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account; acts go through `sudo -n`. +Acts use `--no-wait`: snapd carries them out in the background, and the answer is snapd's change id, +followed with `snapd_changes`. No act outlasts the 20 s a call has. + +| tool | what | +|---|---| +| `snapd_status` (r) | installed or not, version, the four units' enabled and active states, AppArmor in the kernel, `/snap` present, and findings | +| `snapd_list` (r) | every revision: version, revision, tracking, publisher, notes, disabled | +| `snapd_info` (r) | one snap: fields, commands, channels | +| `snapd_updates` (r) | what a refresh would change | +| `snapd_disk_usage` (r) | bytes per snap and revision, the disabled revisions' share, the total | +| `snapd_services` (r) | the services snaps provide | +| `snapd_changes` (r) | recent changes, or one change's tasks | +| `snapd_install` (a) | install, optionally from a channel and in classic confinement | +| `snapd_remove` (a) | remove, keeping a snapshot unless `purge` | +| `snapd_refresh` (a) | refresh one snap, or all | + +## What changes when it is assigned + +Nothing on disk, on either workstation: the module declares nothing. + +- **desktop:** the tools answer for its snaps: `code` (classic), its bases, `gtk-common-themes`, + `gnome-3-28-1804`, `snapd`. +- **laptop:** snapd is not installed, and every tool says so. + +## Leaves as found + +Everything: the package, its units, `/snap`, the installed snaps and their data. diff --git a/modules/snapd/cmd/snapd-tools/kit.go b/modules/snapd/cmd/snapd-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/snapd/cmd/snapd-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/snapd/cmd/snapd-tools/kit_test.go b/modules/snapd/cmd/snapd-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/snapd/cmd/snapd-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/snapd/cmd/snapd-tools/main.go b/modules/snapd/cmd/snapd-tools/main.go new file mode 100644 index 0000000..be6a51b --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/main.go @@ -0,0 +1,153 @@ +// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their +// revisions and the space they take, the store's pending updates, snapd's changes, and installing, +// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188, +// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`. +// +// The module installs nothing: snapd is not in the distribution's official repositories (README). +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)", + "systemctl": "the systemd package", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var nameArg = map[string]any{"type": "string", "description": "the snap's name"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "snapd_status", + Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " + + "whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " + + "for classic snaps. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Status() }, + }, + { + Name: "snapd_list", + Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " + + "whether the revision is disabled (kept for rollback, taking space). (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return List() }, + }, + { + Name: "snapd_info", + Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)", + Input: map[string]any{"name": nameArg}, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + return Info(name) + }, + }, + { + Name: "snapd_updates", + Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Updates() }, + }, + { + Name: "snapd_disk_usage", + Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " + + "kept for rollback, and the total. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return DiskUsage() }, + }, + { + Name: "snapd_services", + Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Services() }, + }, + { + Name: "snapd_changes", + Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " + + "one change's tasks. An act answers the change id to follow here. (r)", + Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}}, + Run: func(args map[string]any) (any, error) { + id, err := optText(args, "id", "") + if err != nil { + return nil, err + } + return Changes(id) + }, + }, + { + Name: "snapd_install", + Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " + + "carries it out in the background; the answer is the change id to follow with snapd_changes. (a)", + Input: map[string]any{ + "name": nameArg, + "channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"}, + "classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"}, + }, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + channel, err := optText(args, "channel", "") + if err != nil { + return nil, err + } + classic, err := optFlag(args, "classic", false) + if err != nil { + return nil, err + } + return Install(name, channel, classic) + }, + }, + { + Name: "snapd_remove", + Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)", + Input: map[string]any{ + "name": nameArg, + "purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"}, + }, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + purge, err := optFlag(args, "purge", false) + if err != nil { + return nil, err + } + return Remove(name, purge) + }, + }, + { + Name: "snapd_refresh", + Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)", + Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}}, + Run: func(args map[string]any) (any, error) { + name, err := optText(args, "name", "") + if err != nil { + return nil, err + } + if name != "" { + if err := checkSnapName(name); err != nil { + return nil, err + } + } + return Refresh(name) + }, + }, + } +} diff --git a/modules/snapd/cmd/snapd-tools/manifest_kit_test.go b/modules/snapd/cmd/snapd-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/snapd/cmd/snapd-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/snapd/cmd/snapd-tools/snapd.go b/modules/snapd/cmd/snapd-tools/snapd.go new file mode 100644 index 0000000..9b0ef1b --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/snapd.go @@ -0,0 +1,437 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// snapNames are what the store accepts as a snap's name, optionally with an instance key. +var snapNames = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}(_[a-z0-9]{1,10})?$`) + +func checkSnapName(name string) error { + if !snapNames.MatchString(name) { + return fmt.Errorf("%q is not a snap name", name) + } + return nil +} + +func snapName(args map[string]any) (string, error) { + name, err := text(args, "name") + if err != nil { + return "", err + } + return name, checkSnapName(name) +} + +var plain = []string{"--unicode=never", "--color=never"} + +// Where snapd keeps what it knows. Tests point these elsewhere. +var ( + snapsDir = "/var/lib/snapd/snaps" + lsmFile = "/sys/kernel/security/lsm" + snapRoot = "/snap" +) + +// UnitState is one unit's state. +type UnitState struct { + Unit string `json:"unit"` + Enabled string `json:"enabled"` + Active string `json:"active"` +} + +// StatusAnswer is what snapd_status answers. +type StatusAnswer struct { + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Units []UnitState `json:"units"` + AppArmor bool `json:"kernel_apparmor"` + ClassicRoot bool `json:"classic_root"` + Findings []string `json:"findings"` +} + +// Status says whether snapd is here and working. +func Status() (StatusAnswer, error) { + out := StatusAnswer{Units: []UnitState{}, Findings: []string{}} + r := run(Cmd{Name: "snap", Args: []string{"version"}}) + if r.Error == "not-found" { + out.Findings = append(out.Findings, "snapd is not installed on this machine") + return out, nil + } + if r.Status != 0 || r.Error != "" { + return out, failure(Cmd{Name: "snap", Args: []string{"version"}}, r) + } + out.Installed = true + for _, l := range lines(r.Stdout) { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "snapd" { + out.Version = f[1] + } + } + for _, u := range []string{"snapd.socket", "snapd.service", "snapd.apparmor.service", "apparmor.service"} { + // is-enabled and is-active answer on stdout and exit non-zero for "disabled" and "inactive": + // a state, not a failure. + en := run(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}) + ac := run(Cmd{Name: "systemctl", Args: []string{"is-active", u}}) + if en.Error != "" || ac.Error != "" { + return out, failure(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}, en) + } + out.Units = append(out.Units, UnitState{Unit: u, Enabled: firstLine(en.Stdout), Active: firstLine(ac.Stdout)}) + } + if b, err := os.ReadFile(lsmFile); err == nil { + for _, m := range strings.Split(strings.TrimSpace(string(b)), ",") { + out.AppArmor = out.AppArmor || m == "apparmor" + } + } + _, err := os.Stat(snapRoot) + out.ClassicRoot = err == nil + if out.Units[0].Enabled != "enabled" { + out.Findings = append(out.Findings, "snapd.socket is not enabled: snapd does not start on demand") + } + if !out.AppArmor { + out.Findings = append(out.Findings, "the kernel does not run AppArmor: strict snaps run without their confinement") + } + if out.Units[2].Enabled == "enabled" && !out.AppArmor { + out.Findings = append(out.Findings, "snapd.apparmor.service is enabled with no AppArmor in the kernel: it loads profiles nothing enforces") + } + if !out.ClassicRoot { + out.Findings = append(out.Findings, "/snap does not exist: classic snaps cannot run") + } + return out, nil +} + +// Snap is one installed revision. +type Snap struct { + Name string `json:"name"` + Version string `json:"version"` + Revision string `json:"revision"` + Tracking string `json:"tracking"` + Publisher string `json:"publisher"` + Notes []string `json:"notes"` + Disabled bool `json:"disabled"` +} + +// ListAnswer is what snapd_list answers. +type ListAnswer struct { + Snaps []Snap `json:"snaps"` + Active int `json:"active"` + Disabled int `json:"disabled_revisions"` +} + +// columns reads a table snap prints: a header line, then whitespace-separated columns, the last +// taking the rest of the line. +func columns(s string, n int) [][]string { + out := [][]string{} + for i, l := range lines(s) { + if i == 0 { + continue + } + f := strings.Fields(l) + if len(f) < n { + continue + } + if len(f) > n { + f = append(f[:n-1], strings.Join(f[n-1:], " ")) + } + out = append(out, f) + } + return out +} + +// List answers every installed snap and revision. +func List() (ListAnswer, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"list", "--all"}, plain...)}) + if err != nil { + return ListAnswer{}, err + } + out := ListAnswer{Snaps: []Snap{}} + for _, f := range columns(r.Stdout, 6) { + s := Snap{Name: f[0], Version: f[1], Revision: f[2], Tracking: f[3], Publisher: strings.TrimRight(f[4], "*"), Notes: []string{}} + if f[5] != "-" { + s.Notes = strings.Split(f[5], ",") + } + for _, n := range s.Notes { + s.Disabled = s.Disabled || n == "disabled" + } + if s.Disabled { + out.Disabled++ + } else { + out.Active++ + } + out.Snaps = append(out.Snaps, s) + } + return out, nil +} + +// InfoAnswer is what snapd_info answers. +type InfoAnswer struct { + Name string `json:"name"` + Fields map[string]string `json:"fields"` + Commands []string `json:"commands"` + Channels map[string]string `json:"channels"` +} + +// Info reads snap info's YAML-like answer: top-level key: value lines, and the commands and +// channels blocks. +func Info(name string) (InfoAnswer, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"info"}, append(plain, name)...)}) + if err != nil { + return InfoAnswer{}, err + } + out := InfoAnswer{Name: name, Fields: map[string]string{}, Commands: []string{}, Channels: map[string]string{}} + block := "" + for _, l := range strings.Split(r.Stdout, "\n") { + if strings.TrimSpace(l) == "" { + continue + } + if !strings.HasPrefix(l, " ") { + block = "" + k, v, found := strings.Cut(l, ":") + if !found { + continue + } + v = strings.TrimSpace(v) + switch { + case v == "" || v == "|": + block = k + default: + out.Fields[k] = v + } + continue + } + t := strings.TrimSpace(l) + switch block { + case "commands": + out.Commands = append(out.Commands, strings.TrimPrefix(t, "- ")) + case "channels": + if k, v, found := strings.Cut(t, ":"); found { + out.Channels[k] = strings.Join(strings.Fields(v), " ") + } + case "description": + out.Fields["description"] = strings.TrimSpace(out.Fields["description"] + " " + t) + } + } + return out, nil +} + +// Update is one pending refresh. +type Update struct { + Name string `json:"name"` + Version string `json:"version"` + Revision string `json:"revision"` + Size string `json:"size"` + Publisher string `json:"publisher"` +} + +// Updates answers what a refresh would change. +func Updates() (map[string]any, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"refresh", "--list"}, plain...)}) + if err != nil { + return nil, err + } + out := []Update{} + if !strings.Contains(r.Stdout+r.Stderr, "All snaps up to date") { + for _, f := range columns(r.Stdout, 6) { + out = append(out, Update{Name: f[0], Version: f[1], Revision: f[2], Size: f[3], Publisher: strings.TrimRight(f[4], "*")}) + } + } + return map[string]any{"updates": out, "count": len(out)}, nil +} + +// Revision is one revision's file. +type Revision struct { + Revision string `json:"revision"` + Bytes int64 `json:"bytes"` + Disabled bool `json:"disabled"` +} + +// SnapUsage is the space one snap's revisions take. +type SnapUsage struct { + Name string `json:"name"` + Bytes int64 `json:"bytes"` + Revisions []Revision `json:"revisions"` +} + +// DiskAnswer is what snapd_disk_usage answers. +type DiskAnswer struct { + Dir string `json:"dir"` + TotalBytes int64 `json:"total_bytes"` + Reclaimable int64 `json:"disabled_revisions_bytes"` + Snaps []SnapUsage `json:"snaps"` + Note string `json:"note"` +} + +// DiskUsage measures the snap files and marks the disabled revisions. +func DiskUsage() (DiskAnswer, error) { + listed, err := List() + if err != nil { + return DiskAnswer{}, err + } + disabled := map[string]bool{} + for _, s := range listed.Snaps { + if s.Disabled { + disabled[s.Name+"_"+s.Revision] = true + } + } + files, err := filepath.Glob(filepath.Join(snapsDir, "*.snap")) + if err != nil { + return DiskAnswer{}, err + } + out := DiskAnswer{Dir: snapsDir, Snaps: []SnapUsage{}, + Note: "A disabled revision is kept by snapd for rollback (refresh.retain); removing one is `snap remove --revision`, which no tool here does."} + by := map[string]*SnapUsage{} + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return DiskAnswer{}, err + } + base := strings.TrimSuffix(filepath.Base(f), ".snap") + i := strings.LastIndex(base, "_") + if i <= 0 { + continue + } + name, rev := base[:i], base[i+1:] + if by[name] == nil { + by[name] = &SnapUsage{Name: name, Revisions: []Revision{}} + } + d := disabled[base] + by[name].Revisions = append(by[name].Revisions, Revision{Revision: rev, Bytes: info.Size(), Disabled: d}) + by[name].Bytes += info.Size() + out.TotalBytes += info.Size() + if d { + out.Reclaimable += info.Size() + } + } + for _, u := range by { + sort.Slice(u.Revisions, func(i, k int) bool { + a, _ := strconv.Atoi(u.Revisions[i].Revision) + b, _ := strconv.Atoi(u.Revisions[k].Revision) + return a < b + }) + out.Snaps = append(out.Snaps, *u) + } + sort.Slice(out.Snaps, func(i, k int) bool { return out.Snaps[i].Bytes > out.Snaps[k].Bytes }) + return out, nil +} + +// Services answers the snaps' services. +func Services() (map[string]any, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"services"}, plain...)}) + if err != nil { + return nil, err + } + out := []map[string]string{} + if !strings.Contains(r.Stdout+r.Stderr, "no services") { + for _, f := range columns(r.Stdout, 4) { + out = append(out, map[string]string{"service": f[0], "startup": f[1], "current": f[2], "notes": f[3]}) + } + } + return map[string]any{"services": out}, nil +} + +// Change is one of snapd's changes, or one task of a change. +type Change struct { + ID string `json:"id,omitempty"` + Status string `json:"status"` + Spawn string `json:"spawn"` + Ready string `json:"ready,omitempty"` + Summary string `json:"summary"` +} + +var changeID = regexp.MustCompile(`^[0-9]+$`) + +// Changes answers snapd's recent changes, or one change's tasks. +func Changes(id string) (map[string]any, error) { + args := append([]string{"changes", "--abs-time"}, plain...) + n := 5 + if id != "" { + if !changeID.MatchString(id) { + return nil, fmt.Errorf("%q is not a change id", id) + } + args, n = append([]string{"tasks", "--abs-time"}, append(plain, id)...), 4 + } + r := run(Cmd{Name: "snap", Args: args}) + if strings.Contains(r.Stdout+r.Stderr, "no changes found") { + return map[string]any{"changes": []Change{}}, nil + } + if r.Status != 0 || r.Error != "" { + return nil, failure(Cmd{Name: "snap", Args: args}, r) + } + out := []Change{} + for _, f := range columns(r.Stdout, n) { + c := Change{} + if n == 5 { + c.ID, f = f[0], f[1:] + } + c.Status, c.Spawn, c.Ready, c.Summary = f[0], f[1], f[2], f[3] + if c.Ready == "-" { + c.Ready = "" + } + out = append(out, c) + } + if id != "" { + return map[string]any{"id": id, "tasks": out}, nil + } + return map[string]any{"changes": out}, nil +} + +// ActAnswer is what an act answers: the change snapd carries it out in. +type ActAnswer struct { + Act string `json:"act"` + Snap string `json:"snap,omitempty"` + Change string `json:"change,omitempty"` + Said string `json:"said,omitempty"` + Follow string `json:"follow,omitempty"` +} + +// act runs one snap act with --no-wait, which answers snapd's change id at once. +func act(verb, name string, extra ...string) (ActAnswer, error) { + args := append([]string{verb, "--no-wait"}, extra...) + if name != "" { + args = append(args, name) + } + r, err := call(Cmd{Name: "snap", Args: args, Root: true}) + if err != nil { + return ActAnswer{}, err + } + out := ActAnswer{Act: verb, Snap: name} + if id := strings.TrimSpace(r.Stdout); changeID.MatchString(id) { + out.Change, out.Follow = id, "snapd_changes with id "+id + } else { + out.Said = strings.TrimSpace(r.Stdout + "\n" + r.Stderr) + } + return out, nil +} + +var channelName = regexp.MustCompile(`^[a-z0-9][a-z0-9./_-]*$`) + +// Install installs a snap. +func Install(name, channel string, classic bool) (ActAnswer, error) { + extra := []string{} + if channel != "" { + if !channelName.MatchString(channel) { + return ActAnswer{}, fmt.Errorf("%q is not a channel", channel) + } + extra = append(extra, "--channel="+channel) + } + if classic { + extra = append(extra, "--classic") + } + return act("install", name, extra...) +} + +// Remove removes a snap. +func Remove(name string, purge bool) (ActAnswer, error) { + if purge { + return act("remove", name, "--purge") + } + return act("remove", name) +} + +// Refresh refreshes one snap, or all. +func Refresh(name string) (ActAnswer, error) { + return act("refresh", name) +} diff --git a/modules/snapd/cmd/snapd-tools/snapd_test.go b/modules/snapd/cmd/snapd-tools/snapd_test.go new file mode 100644 index 0000000..98b523a --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/snapd_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTheManifestDeclaresNothingTheHostCouldNotInstall(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "snapd") + // snapd is not in the official repositories: no package, and no unit that only that package + // brings, until the mesh's package repository builds it (research 027 question 1). + if len(m.Resources) != 0 { + t.Errorf("resources %v", m.Resources) + } +} + +const listAll = `Name Version Rev Tracking Publisher Notes +bare 1.0 5 latest/stable canonical** base +code 04c0d99f 266 latest/stable vscode** disabled,classic +code 07f806f9 267 latest/stable vscode** classic +gtk-common-themes 0.1-81-g442e511 1535 latest/stable canonical** - +` + +func TestListReadsEachRevisionAndMarksTheDisabledOnes(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok(listAll) }) + got, err := List() + if err != nil || len(got.Snaps) != 4 || got.Disabled != 1 || got.Active != 3 { + t.Fatalf("%+v %v", got, err) + } + if s := got.Snaps[1]; s.Name != "code" || s.Revision != "266" || !s.Disabled || s.Publisher != "vscode" || strings.Join(s.Notes, ",") != "disabled,classic" { + t.Errorf("%+v", s) + } + if s := got.Snaps[3]; len(s.Notes) != 0 || s.Disabled { + t.Errorf("%+v", s) + } + if f.lines()[0] != "snap list --all --unicode=never --color=never" { + t.Errorf("%v", f.lines()) + } +} + +func TestToolsSayWhenSnapdIsNotInstalled(t *testing.T) { + using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} }) + if _, err := List(); err == nil || !strings.Contains(err.Error(), "not in the official repositories") { + t.Fatalf("%v", err) + } + got, err := Status() + if err != nil || got.Installed || !strings.Contains(strings.Join(got.Findings, ";"), "not installed") { + t.Fatalf("%+v %v", got, err) + } +} + +func TestStatusNamesWhatIsWrongWithTheInstallation(t *testing.T) { + dir := t.TempDir() + wasLSM, wasRoot := lsmFile, snapRoot + lsmFile, snapRoot = filepath.Join(dir, "lsm"), filepath.Join(dir, "snap") + defer func() { lsmFile, snapRoot = wasLSM, wasRoot }() + _ = os.WriteFile(lsmFile, []byte("capability,landlock,lockdown,yama,bpf\n"), 0o644) + _ = os.Mkdir(snapRoot, 0o755) + using(t, func(line string, c Cmd) Result { + switch { + case line == "snap version": + return ok("snap 2.76.2-2\nsnapd 2.76.2-2\nseries 16\n") + case strings.HasSuffix(line, "is-enabled snapd.service"), strings.HasSuffix(line, "is-enabled apparmor.service"): + return Result{Status: 1, Stdout: "disabled\n"} + case strings.Contains(line, "is-enabled"): + return ok("enabled\n") + case strings.Contains(line, "is-active snapd.service"): + return ok("active\n") + } + return Result{Status: 3, Stdout: "inactive\n"} + }) + got, err := Status() + if err != nil || !got.Installed || got.Version != "2.76.2-2" || got.AppArmor || !got.ClassicRoot || len(got.Units) != 4 { + t.Fatalf("%+v %v", got, err) + } + if got.Units[1].Enabled != "disabled" || got.Units[1].Active != "active" { + t.Errorf("socket-activated service: %+v", got.Units[1]) + } + all := strings.Join(got.Findings, ";") + if !strings.Contains(all, "without their confinement") || !strings.Contains(all, "nothing enforces") || strings.Contains(all, "/snap does not exist") { + t.Errorf("%s", all) + } +} + +func TestInfoReadsFieldsCommandsAndChannels(t *testing.T) { + using(t, func(string, Cmd) Result { + return ok(`name: code +summary: Code editing. Redefined. +publisher: Visual Studio Code (vscode**) +license: unset +description: | + Visual Studio Code is a new choice + of tool. +commands: + - code + - code.url-handler +tracking: latest/stable +channels: + latest/stable: 07f806f9 2026-09-30 (267) 543MB classic + latest/candidate: ^ +`) + }) + got, err := Info("code") + if err != nil || got.Fields["summary"] != "Code editing. Redefined." || len(got.Commands) != 2 || got.Fields["tracking"] != "latest/stable" { + t.Fatalf("%+v %v", got, err) + } + if got.Channels["latest/stable"] != "07f806f9 2026-09-30 (267) 543MB classic" || got.Fields["description"] != "Visual Studio Code is a new choice of tool." { + t.Errorf("%+v", got) + } +} + +func TestUpdatesAndChangesReadNothingToDoAsEmpty(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "refresh --list") { + return Result{Stderr: "All snaps up to date.\n"} + } + return Result{Status: 1, Stderr: "error: no changes found\n"} + }) + u, err := Updates() + if err != nil || u["count"] != 0 { + t.Fatalf("%v %v", u, err) + } + c, err := Changes("") + if err != nil || len(c["changes"].([]Change)) != 0 { + t.Fatalf("%v %v", c, err) + } + if _, err := Changes("12; reboot"); err == nil { + t.Error("a change id that is not a number") + } +} + +func TestChangesAndTasksAreReadByColumn(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "tasks") { + return ok("Status Spawn Ready Summary\nDone 2026-10-01T18:57:00+02:00 2026-10-01T18:57:10+02:00 Download snap \"code\" (267)\n") + } + return ok("ID Status Spawn Ready Summary\n42 Doing 2026-10-04T10:00:00+02:00 - Install \"hello\" snap\n") + }) + c, err := Changes("") + ch := c["changes"].([]Change) + if err != nil || len(ch) != 1 || ch[0].ID != "42" || ch[0].Ready != "" || ch[0].Summary != `Install "hello" snap` { + t.Fatalf("%+v %v", c, err) + } + c, _ = Changes("42") + if tasks := c["tasks"].([]Change); len(tasks) != 1 || tasks[0].Status != "Done" || tasks[0].Summary != `Download snap "code" (267)` { + t.Errorf("%+v", c) + } +} + +func TestActsGoThroughSudoWithoutWaitingAndAnswerTheChange(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "refresh") { + return Result{Stderr: "All snaps up to date.\n"} + } + return ok("57\n") + }) + got, err := Install("hello-world", "latest/edge", true) + if err != nil || got.Change != "57" || !strings.Contains(got.Follow, "57") { + t.Fatalf("%+v %v", got, err) + } + if _, err := Remove("hello-world", true); err != nil { + t.Fatal(err) + } + r, err := Refresh("") + if err != nil || r.Change != "" || !strings.Contains(r.Said, "up to date") { + t.Fatalf("%+v %v", r, err) + } + want := "sudo -n snap install --no-wait --channel=latest/edge --classic hello-world\n" + + "sudo -n snap remove --no-wait --purge hello-world\n" + + "sudo -n snap refresh --no-wait" + if got := strings.Join(f.lines(), "\n"); got != want { + t.Errorf("asked\n%s", got) + } + for _, bad := range []string{"--classic", "Hello", "a b", "../x"} { + if err := checkSnapName(bad); err == nil { + t.Errorf("%q accepted as a snap name", bad) + } + } + if _, err := Install("x", "--dangerous", false); err == nil { + t.Error("an option as a channel") + } +} + +func TestDiskUsageMeasuresEachRevisionAndWhatDisabledOnesTake(t *testing.T) { + dir := t.TempDir() + was := snapsDir + snapsDir = dir + defer func() { snapsDir = was }() + for name, size := range map[string]int{"code_266.snap": 500, "code_267.snap": 510, "bare_5.snap": 4} { + _ = os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o600) + } + using(t, func(string, Cmd) Result { return ok(listAll) }) + got, err := DiskUsage() + if err != nil || got.TotalBytes != 1014 || got.Reclaimable != 500 || len(got.Snaps) != 2 { + t.Fatalf("%+v %v", got, err) + } + if c := got.Snaps[0]; c.Name != "code" || c.Bytes != 1010 || !c.Revisions[0].Disabled || c.Revisions[1].Disabled { + t.Errorf("%+v", c) + } +} diff --git a/modules/snapd/go.mod b/modules/snapd/go.mod new file mode 100644 index 0000000..fdb3da4 --- /dev/null +++ b/modules/snapd/go.mod @@ -0,0 +1,5 @@ +module snapd + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/snapd/go.sum b/modules/snapd/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/snapd/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/snapd/module.json b/modules/snapd/module.json new file mode 100644 index 0000000..93ad730 --- /dev/null +++ b/modules/snapd/module.json @@ -0,0 +1,31 @@ +{ + "module": "snapd", + "version": "1", + "tools": [ + "snapd_status", + "snapd_list", + "snapd_info", + "snapd_updates", + "snapd_disk_usage", + "snapd_services", + "snapd_changes", + "snapd_install", + "snapd_remove", + "snapd_refresh" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/snapd-tools", + "binary": "snapd-tools", + "loads": [ + "snapd-tools" + ] + } + ] + } +} diff --git a/modules/xclip/README.md b/modules/xclip/README.md new file mode 100644 index 0000000..f31253e --- /dev/null +++ b/modules/xclip/README.md @@ -0,0 +1,68 @@ +# xclip + +The command-line X clipboard, as a module (novox/hq research 026/04: "`xclip` is a module of its own, +a package and nothing else. It is the tool scripts depend on, and a module that needs it requires +it"; to-be 42 phase 2 step 7). + +## Owns + +| what | where | +|---|---| +| `xclip` | package `xclip` (official repositories) | + +Nothing else. The clipboard's history is the clipboard manager's (`node-clipboard`, a later module). +This one is the plain clipboard, without a manager. + +## Improves + +- **A declared dependency.** The window manager's screenshot script copies through `xclip` on both + workstations, and nothing said it needed it. A module that ships such a script requires this one. +- **The clipboard from the mesh.** Text can be put on the operator's clipboard from any machine, for + example a command or a link prepared elsewhere, and read back. That is safe with a clipboard manager + too: the manager takes what `xclip` offers into its history. + +## Reaching the operator's session + +The node's tool runtime is a system service running as the operator account. It is given no session +words: no `DISPLAY`, no `XAUTHORITY`. Measured on both workstations on 2026-10-04: + +- the runtime runs as the account, in the machine's own mount namespace, with no private `/tmp`; +- the X server's socket is `/tmp/.X11-unix/X1`; +- the cookie is in `~/.Xauthority`, readable by the account; +- the window manager's environment names both. + +So a child of the runtime **can** reach the session. `session.go` finds it in this order: + +1. the process's own `DISPLAY`; +2. else the `DISPLAY` and `XAUTHORITY` of the account's running processes, read from + `/proc//environ` (the window manager's by preference), whose socket exists; +3. else the only X socket, with `~/.Xauthority`. + +Checked from a shell with both variables unset: it found `:1` through the window manager's process, +and the server answered. With no session (nobody logged in to the desktop), every tool answers that no +graphical session of the account is running, and runs nothing. A server that refuses the cookie is +named with the display and how it was found. + +**What it does not cover.** A Wayland session: there `wl-clipboard` is the tool, and a Wayland module +carries it (research 026/04). Also, a copy is held by `xclip`'s own background process, a child of the +tool bundle. If the runtime restarts the bundle before another program takes the selection, the copied +text is gone. + +## Tools + +All answer JSON. `(r)` reads; `(d)` acts in the operator's session. + +| tool | what | +|---|---| +| `xclip_copy` (d) | put text (at most 1 MiB) on the clipboard, primary or secondary selection, as a given type (default UTF-8 text) | +| `xclip_paste` (r) | what a selection holds: text, or base64 for a type that is not text; `empty` when there is nothing; cut at 256 KiB | +| `xclip_targets` (r) | the types a selection is offered as, without reading it | +| `xclip_session` (r) | the session the tools reach and how it was found, or why there is none, and whether the server answers | + +## What changes when it is assigned + +Nothing on disk on either workstation: `xclip` is installed explicitly on both. + +## Leaves as found + +The scripts that call `xclip` (the window manager's screenshot script), which are their own modules'. diff --git a/modules/xclip/cmd/xclip-tools/kit.go b/modules/xclip/cmd/xclip-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/xclip/cmd/xclip-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/xclip/cmd/xclip-tools/kit_test.go b/modules/xclip/cmd/xclip-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/xclip/cmd/xclip-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/xclip/cmd/xclip-tools/main.go b/modules/xclip/cmd/xclip-tools/main.go new file mode 100644 index 0000000..07c8fc0 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/main.go @@ -0,0 +1,102 @@ +// The xclip module's tools (novox/hq research 026/04, 026/05): the plain X clipboard without a +// manager. Copy puts text on a selection, paste reads one, targets says what a selection offers. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account +// and reaches the account's X session as session.go finds it; with no session, every tool says so. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "xclip": "the xclip package, which this module installs", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var selectionArg = map[string]any{"type": "string", "enum": Selections, "description": "which selection: clipboard (default), primary or secondary"} + +func selectionOf(args map[string]any) (string, error) { + s, err := optText(args, "selection", "clipboard") + if err != nil { + return "", err + } + return s, oneOf("selection", s, Selections...) +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "xclip_copy", + Description: "Put text on the operator's clipboard (or the primary or secondary selection), as a given type " + + "(default plain UTF-8 text). Needs the operator's graphical session. (d)", + Input: map[string]any{ + "text": map[string]any{"type": "string", "description": "what to copy, at most 1 MiB"}, + "selection": selectionArg, + "type": map[string]any{"type": "string", "description": "the content's type, such as text/html (default UTF8_STRING)"}, + }, + Run: func(args map[string]any) (any, error) { + t, ok := args["text"].(string) + if !ok { + return nil, fmt.Errorf("text is required, as a string (it may be empty)") + } + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + typ, err := optText(args, "type", "") + if err != nil { + return nil, err + } + return Copy(t, sel, typ) + }, + }, + { + Name: "xclip_paste", + Description: "What the operator's clipboard (or another selection) holds now, as text, or as base64 for a " + + "type that is not text. Cut at 256 KiB. Needs the operator's graphical session. (r)", + Input: map[string]any{ + "selection": selectionArg, + "type": map[string]any{"type": "string", "description": "the type to ask for, such as text/html or image/png (default UTF8_STRING)"}, + }, + Run: func(args map[string]any) (any, error) { + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + typ, err := optText(args, "type", "") + if err != nil { + return nil, err + } + return Paste(sel, typ) + }, + }, + { + Name: "xclip_targets", + Description: "The types a selection is offered as right now (text, HTML, an image…), without reading the content. (r)", + Input: map[string]any{"selection": selectionArg}, + Run: func(args map[string]any) (any, error) { + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + return Targets(sel) + }, + }, + { + Name: "xclip_session", + Description: "Which X session the clipboard tools reach and how it was found, or why there is none; and " + + "whether the X server answers. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return SessionCheck() }, + }, + } +} diff --git a/modules/xclip/cmd/xclip-tools/manifest_kit_test.go b/modules/xclip/cmd/xclip-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/xclip/cmd/xclip-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/xclip/cmd/xclip-tools/session.go b/modules/xclip/cmd/xclip-tools/session.go new file mode 100644 index 0000000..43ba119 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/session.go @@ -0,0 +1,177 @@ +package main + +// session.go is the same file in the bundles whose tools act in the operator's graphical session +// (xclip, dmenu): how a process the node's tool runtime launched reaches that session. +// +// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the +// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X +// server accepts a client that names its display and presents the cookie in the authority file, and +// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is +// readable by the account. So the session is found, not configured: +// +// 1. the process's own DISPLAY, when the runtime happens to have one; +// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from +// /proc//environ (the window manager's, by preference), whose socket exists; +// 3. else the only X socket there is, with the authority file in the account's home. +// +// When none is found the tool says that no graphical session of the account is running, and does +// nothing. + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + +// Session is the operator's X session as a tool reaches it. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + // FoundBy says how: "environment", "process ()" or "socket". + FoundBy string `json:"found_by"` +} + +// Env is what a command needs to reach the session. +func (s Session) Env() []string { + env := []string{"DISPLAY=" + s.Display} + if s.XAuthority != "" { + env = append(env, "XAUTHORITY="+s.XAuthority) + } + return env +} + +// Where the session is looked for. Tests point these at a tree of their own. +var ( + procRoot = "/proc" + x11Sockets = "/tmp/.X11-unix" + getenv = os.Getenv + myUID = os.Getuid +) + +// sessionWMs are the programs whose environment is the session's own, preferred over any other +// process's (a terminal's child may carry a stale or forwarded DISPLAY). +var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true} + +func accountHome() string { + if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + if h := strings.TrimSpace(getenv("HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one. +func socketOf(display string) string { + if !strings.HasPrefix(display, ":") { + return "" + } + n := strings.TrimPrefix(display, ":") + if i := strings.IndexByte(n, '.'); i >= 0 { + n = n[:i] + } + if _, err := strconv.Atoi(n); err != nil { + return "" + } + return filepath.Join(x11Sockets, "X"+n) +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// findSession answers the account's X session, or an error saying there is none. +func findSession() (Session, error) { + if d := strings.TrimSpace(getenv("DISPLAY")); d != "" { + if s := socketOf(d); s == "" || exists(s) { + return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil + } + } + type seen struct { + Session + wm bool + count int + } + found := map[string]*seen{} + entries, _ := os.ReadDir(procRoot) + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || !e.IsDir() { + continue + } + dir := filepath.Join(procRoot, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + var display, auth string + for _, kv := range strings.Split(string(raw), "\x00") { + switch { + case strings.HasPrefix(kv, "DISPLAY="): + display = strings.TrimPrefix(kv, "DISPLAY=") + case strings.HasPrefix(kv, "XAUTHORITY="): + auth = strings.TrimPrefix(kv, "XAUTHORITY=") + } + } + if display == "" { + continue + } + if s := socketOf(display); s == "" || !exists(s) { + continue + } + comm, _ := os.ReadFile(filepath.Join(dir, "comm")) + name := strings.TrimSpace(string(comm)) + key := display + "\x00" + auth + if found[key] == nil { + found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}} + } + f := found[key] + f.count++ + if sessionWMs[name] && !f.wm { + f.wm = true + f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name) + } + } + if len(found) > 0 { + all := make([]*seen, 0, len(found)) + for _, f := range found { + all = append(all, f) + } + sort.Slice(all, func(i, k int) bool { + if all[i].wm != all[k].wm { + return all[i].wm + } + if all[i].count != all[k].count { + return all[i].count > all[k].count + } + return all[i].Display < all[k].Display + }) + return all[0].Session, nil + } + sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*")) + if len(sockets) == 1 { + s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"} + if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) { + s.XAuthority = a + } + return s, nil + } + if len(sockets) > 1 { + return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets) + } + return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets) +} diff --git a/modules/xclip/cmd/xclip-tools/session_test.go b/modules/xclip/cmd/xclip-tools/session_test.go new file mode 100644 index 0000000..707e754 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/session_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// aMachine gives findSession a /proc and an X socket directory of the test's own. +func aMachine(t *testing.T, env map[string]string) (proc, sockets string) { + t.Helper() + root := t.TempDir() + proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11") + for _, d := range []string{proc, sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv + procRoot, x11Sockets = proc, sockets + getenv = func(k string) string { return env[k] } + t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv }) + return proc, sockets +} + +func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644) +} + +func aSocket(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) { + proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"}) + aSocket(t, sockets, "X1") + aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale") + aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 6, "sshd", "PATH=/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") { + t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s) + } + if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" { + t.Fatalf("env %s", got) + } +} + +func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) { + home := t.TempDir() + _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600) + _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home}) + aSocket(t, sockets, "X0") + s, err := findSession() + if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) { + _, sockets := aMachine(t, map[string]string{}) + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("none: %v", err) + } + aSocket(t, sockets, "X0") + aSocket(t, sockets, "X1") + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") { + t.Fatalf("two: %v", err) + } +} + +func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) { + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"}) + aSocket(t, sockets, "X2") + s, err := findSession() + if err != nil || s.Display != ":2" || s.FoundBy != "environment" { + t.Fatalf("%+v %v", s, err) + } +} diff --git a/modules/xclip/cmd/xclip-tools/xclip.go b/modules/xclip/cmd/xclip-tools/xclip.go new file mode 100644 index 0000000..27aa661 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/xclip.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// Selections are the X selections xclip reaches. +var Selections = []string{"clipboard", "primary", "secondary"} + +// MostCopy bounds what a caller may put on the clipboard. +const MostCopy = 1 << 20 + +var targetName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.+/;=-]{0,127}$`) + +func checkTarget(t string) error { + if t != "" && !targetName.MatchString(t) { + return fmt.Errorf("%q is not a type a selection can be offered as", t) + } + return nil +} + +// xclip runs xclip in the session, naming an unreachable display as such. +func xclip(s Session, c Cmd) (Result, error) { + c.Name, c.Env = "xclip", s.Env() + r := run(c) + said := r.Stderr + r.Stdout + switch { + case r.Error != "": + return r, failure(c, r) + case strings.Contains(said, "Can't open display"): + return r, fmt.Errorf("the X session at %s (found by %s) refused the connection: the display is gone, or the cookie in %s is not the server's", s.Display, s.FoundBy, orNone(s.XAuthority)) + case r.Status != 0: + return r, failure(c, r) + } + return r, nil +} + +func orNone(s string) string { + if s == "" { + return "(no authority file)" + } + return s +} + +// CopyAnswer is what xclip_copy answers. +type CopyAnswer struct { + Selection string `json:"selection"` + Type string `json:"type"` + Bytes int `json:"bytes"` + Session Session `json:"session"` + Note string `json:"note"` +} + +// Copy puts text on a selection. xclip forks a process that holds the selection until another +// program takes it, so the command runs detached. +func Copy(text, selection, typ string) (CopyAnswer, error) { + if len(text) > MostCopy { + return CopyAnswer{}, fmt.Errorf("text is %d bytes; at most %d are copied", len(text), MostCopy) + } + if err := checkTarget(typ); err != nil { + return CopyAnswer{}, err + } + s, err := findSession() + if err != nil { + return CopyAnswer{}, err + } + args := []string{"-selection", selection, "-in"} + if typ != "" { + args = append(args, "-t", typ) + } + if _, err := xclip(s, Cmd{Args: args, Stdin: text, Detached: true}); err != nil { + return CopyAnswer{}, err + } + if typ == "" { + typ = "UTF8_STRING" + } + return CopyAnswer{Selection: selection, Type: typ, Bytes: len(text), Session: s, + Note: "xclip holds the selection until another program takes it; a clipboard manager may copy it into its history"}, nil +} + +// PasteAnswer is what xclip_paste answers. +type PasteAnswer struct { + Selection string `json:"selection"` + Type string `json:"type"` + Empty bool `json:"empty"` + Text string `json:"text,omitempty"` + Base64 string `json:"base64,omitempty"` + Bytes int `json:"bytes"` + Truncated bool `json:"truncated,omitempty"` +} + +// Paste reads a selection. +func Paste(selection, typ string) (PasteAnswer, error) { + if err := checkTarget(typ); err != nil { + return PasteAnswer{}, err + } + s, err := findSession() + if err != nil { + return PasteAnswer{}, err + } + args := []string{"-selection", selection, "-out"} + if typ != "" { + args = append(args, "-t", typ) + } else { + typ = "UTF8_STRING" + } + out := PasteAnswer{Selection: selection, Type: typ} + r, err := xclip(s, Cmd{Args: args}) + if err != nil { + // "Error: target … not available": the selection is empty, or not offered as that type. + if strings.Contains(r.Stderr, "not available") { + out.Empty = true + return out, nil + } + return PasteAnswer{}, err + } + out.Bytes, out.Truncated = len(r.Stdout), r.Truncated + if utf8.ValidString(r.Stdout) { + out.Text = r.Stdout + } else { + out.Base64 = base64.StdEncoding.EncodeToString([]byte(r.Stdout)) + } + out.Empty = out.Bytes == 0 + return out, nil +} + +// Targets answers the types a selection is offered as. +func Targets(selection string) (map[string]any, error) { + s, err := findSession() + if err != nil { + return nil, err + } + r, err := xclip(s, Cmd{Args: []string{"-selection", selection, "-out", "-t", "TARGETS"}}) + if err != nil { + if strings.Contains(r.Stderr, "not available") { + return map[string]any{"selection": selection, "targets": []string{}, "empty": true}, nil + } + return nil, err + } + return map[string]any{"selection": selection, "targets": lines(r.Stdout), "empty": strings.TrimSpace(r.Stdout) == ""}, nil +} + +// SessionCheck answers the session and whether its X server answers. +func SessionCheck() (map[string]any, error) { + s, err := findSession() + if err != nil { + return map[string]any{"found": false, "why": err.Error()}, nil + } + out := map[string]any{"found": true, "session": s} + if _, err := Targets("clipboard"); err != nil { + out["answers"], out["why"] = false, err.Error() + } else { + out["answers"] = true + } + return out, nil +} diff --git a/modules/xclip/cmd/xclip-tools/xclip_test.go b/modules/xclip/cmd/xclip-tools/xclip_test.go new file mode 100644 index 0000000..2a455c6 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/xclip_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsThePackageAndNothingElse(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "xclip") + if got := strings.Join(m.packages(), ","); got != "xclip" || len(m.Resources) != 1 { + t.Errorf("packages %s, resources %v", got, m.Resources) + } +} + +// aSession gives the tools an X session on :1 with the account's cookie. +func aSession(t *testing.T) { + t.Helper() + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"}) + aSocket(t, sockets, "X1") +} + +func TestCopyRunsDetachedInTheSessionWithTheTextOnItsInput(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("") }) + got, err := Copy("hello", "clipboard", "") + if err != nil || got.Bytes != 5 || got.Type != "UTF8_STRING" || got.Session.Display != ":1" { + t.Fatalf("%+v %v", got, err) + } + c := f.asked[0] + if f.lines()[0] != "xclip -selection clipboard -in" || c.Stdin != "hello" || !c.Detached { + t.Errorf("%v %+v", f.lines(), c) + } + if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" { + t.Errorf("env %v", c.Env) + } + if _, err := Copy("x", "primary", "text/html"); err != nil || f.lines()[1] != "xclip -selection primary -in -t text/html" { + t.Errorf("%v %v", f.lines(), err) + } + if _, err := Copy(strings.Repeat("a", MostCopy+1), "clipboard", ""); err == nil { + t.Error("more than 1 MiB") + } + if _, err := Copy("x", "clipboard", "-o"); err == nil { + t.Error("an option as a type") + } +} + +func TestPasteAnswersTextBase64OrEmpty(t *testing.T) { + aSession(t) + answer := Result{Stdout: "some text"} + f := using(t, func(string, Cmd) Result { return answer }) + got, err := Paste("clipboard", "") + if err != nil || got.Text != "some text" || got.Bytes != 9 || got.Empty { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "xclip -selection clipboard -out" || f.asked[0].Detached { + t.Errorf("%v", f.lines()) + } + answer = Result{Stdout: "\x89PNG\r\n\x1a\n\xff"} + got, _ = Paste("clipboard", "image/png") + if got.Text != "" || got.Base64 == "" { + t.Errorf("binary: %+v", got) + } + answer = Result{Status: 1, Stderr: "Error: target UTF8_STRING not available\n"} + got, err = Paste("clipboard", "") + if err != nil || !got.Empty { + t.Errorf("an empty clipboard is an answer: %+v %v", got, err) + } +} + +func TestADisplayThatRefusesIsSaidWithHowItWasFound(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "Error: Can't open display: :1\n"} }) + _, err := Paste("clipboard", "") + if err == nil || !strings.Contains(err.Error(), "refused the connection") || !strings.Contains(err.Error(), "environment") { + t.Fatalf("%v", err) + } +} + +func TestWithoutASessionNothingRunsAndTheToolSaysWhy(t *testing.T) { + aMachine(t, map[string]string{}) + f := using(t, func(string, Cmd) Result { return ok("") }) + if _, err := Copy("x", "clipboard", ""); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("%v", err) + } + got, err := SessionCheck() + if err != nil || got["found"] != false || !strings.Contains(got["why"].(string), "logged in") { + t.Fatalf("%v %v", got, err) + } + if len(f.asked) != 0 { + t.Errorf("nothing runs without a session: %v", f.lines()) + } +} + +func TestTargetsListWhatASelectionOffers(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return ok("TIMESTAMP\nTARGETS\nUTF8_STRING\ntext/html\n") }) + got, err := Targets("clipboard") + if err != nil || len(got["targets"].([]string)) != 4 { + t.Fatalf("%v %v", got, err) + } + s, _ := SessionCheck() + if s["answers"] != true { + t.Errorf("%v", s) + } +} diff --git a/modules/xclip/go.mod b/modules/xclip/go.mod new file mode 100644 index 0000000..9284445 --- /dev/null +++ b/modules/xclip/go.mod @@ -0,0 +1,5 @@ +module xclip + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/xclip/go.sum b/modules/xclip/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/xclip/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/xclip/module.json b/modules/xclip/module.json new file mode 100644 index 0000000..bf06f6f --- /dev/null +++ b/modules/xclip/module.json @@ -0,0 +1,35 @@ +{ + "module": "xclip", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "xclip_copy", + "xclip_paste", + "xclip_targets", + "xclip_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xclip" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xclip-tools", + "binary": "xclip-tools", + "loads": [ + "xclip-tools" + ] + } + ] + } +}