bluetooth: the stack, its daemon, and the devices as tools (hq to-be 42 phase 2.9)
bluez and bluez-utils, and bluetooth.service running and enabled. On both workstations bluez is installed only as a dependency; declaring it keeps a clean-up from taking it. Nine Go tools over bluetoothctl: controller, power, devices with battery where reported, a bounded scan, connect, disconnect, trust, pair (an agent that confirms nothing, for headphones) and remove. An act whose output says it failed is an error whatever the exit status, and one bluez refuses the account is repeated through sudo -n.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user