Merge pull request 'Workstation basics: fonts, docker-compose, snapd, flatpak, cups, bluetooth, xclip, dmenu (hq to-be 42 phase 2), tools in Go' (#269) from feat/phase-2-workstation-basics-rebased into main

This commit was merged in pull request #269.
This commit is contained in:
2026-10-04 11:02:42 +00:00
88 changed files with 12564 additions and 0 deletions
+53
View File
@@ -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.
@@ -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 <address> <name>" 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)
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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) }),
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module bluetooth
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+53
View File
@@ -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"
]
}
]
}
}
+84
View File
@@ -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`.
+560
View File
@@ -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
}
+234
View File
@@ -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)
}
}
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+177
View File
@@ -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)
},
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module cups
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+58
View File
@@ -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"
]
}
]
}
}
+56
View File
@@ -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.
+112
View File
@@ -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
}
+108
View File
@@ -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)
}
}
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+70
View File
@@ -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() },
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+177
View File
@@ -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/<pid>/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 <pid> (<name>)" 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)
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module dmenu
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+33
View File
@@ -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"
]
}
]
}
}
+65
View File
@@ -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.
@@ -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...)
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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)
},
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module docker-compose
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+40
View File
@@ -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"
]
}
]
}
}
+80
View File
@@ -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.
@@ -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")
}
@@ -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])
}
}
+151
View File
@@ -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
}
@@ -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)
}
}
+352
View File
@@ -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
}
@@ -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")
}
}
+213
View File
@@ -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)
},
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module flatpak
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+42
View File
@@ -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"
]
}
]
}
}
+99
View File
@@ -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.
+522
View File
@@ -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
}
+302
View File
@@ -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, "<?xml") || !strings.Contains(content, `<!DOCTYPE fontconfig`) {
t.Fatal("not a fontconfig document")
}
if strings.Contains(content, "$") {
t.Error("the file holds no placeholder: it is the same on every machine")
}
var doc fcDoc
if err := xml.Unmarshal([]byte(content), &doc); err != nil {
t.Fatalf("not well-formed: %v", err)
}
firstPrefer := map[string]string{}
order := []string{}
for _, a := range doc.Aliases {
order = append(order, a.Family)
if len(a.Prefer) > 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("<fontconfig/>"), 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)
}
}
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+117
View File
@@ -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)
},
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module fonts
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+65
View File
@@ -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": "<?xml version=\"1.0\"?>\n<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">\n<!--\n The mesh's file (module fonts, novox/hq research 026/04): which face each generic family means for\n this account, so every program that asks for monospace, sans-serif, serif or emoji gets the same\n face. Replaced at every push. Other files in this directory are yours.\n-->\n<fontconfig>\n <description>The mesh: the faces monospace, sans-serif, serif and emoji mean</description>\n\n <!--\n Families the desktop's files named before this module, mapped to monospace for as long as they\n are not installed: a configuration still naming one gets the chosen monospace face rather than\n fontconfig's last resort, sans-serif. Where the family is still installed, it is used as before.\n Placed first, so the monospace they lead to is resolved by the rule below.\n -->\n <alias><family>Hack Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>MesloLGS NF</family><accept><family>monospace</family></accept></alias>\n <alias><family>Iosevka Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>JetBrains Mono Nerd Font</family><accept><family>JetBrainsMono Nerd Font</family></accept></alias>\n\n <!--\n The decided faces, bound the same as the request: a program asking for monospace asks strongly,\n and a face added weakly loses to the distribution's own choice (measured with fontconfig 2.18).\n -->\n <alias binding=\"same\"><family>monospace</family><prefer><family>JetBrainsMono Nerd Font</family></prefer></alias>\n <alias binding=\"same\"><family>sans-serif</family><prefer><family>Inter</family><family>Noto Sans</family></prefer></alias>\n <alias binding=\"same\"><family>system-ui</family><prefer><family>Inter</family></prefer></alias>\n <alias binding=\"same\"><family>serif</family><prefer><family>Noto Serif</family></prefer></alias>\n <alias binding=\"same\"><family>emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>\n\n <!--\n Fallbacks for any face: an icon a face lacks comes from the Nerd Fonts symbols, an emoji from\n Noto Color Emoji. Appended last, so they never displace a face that has the character.\n -->\n <match target=\"pattern\">\n <edit name=\"family\" mode=\"append_last\"><string>Symbols Nerd Font</string></edit>\n <edit name=\"family\" mode=\"append_last\"><string>Noto Color Emoji</string></edit>\n </match>\n</fontconfig>\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/fonts-tools",
"binary": "fonts-tools",
"loads": [
"fonts-tools"
]
}
]
}
}
+71
View File
@@ -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.
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+153
View File
@@ -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)
},
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+437
View File
@@ -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)
}
+203
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module snapd
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+31
View File
@@ -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"
]
}
]
}
}
+68
View File
@@ -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/<pid>/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'.
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+102
View File
@@ -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() },
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+177
View File
@@ -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/<pid>/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 <pid> (<name>)" 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)
}
@@ -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)
}
}
+160
View File
@@ -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
}
+106
View File
@@ -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("<b>x</b>", "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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module xclip
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+35
View File
@@ -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"
]
}
]
}
}