diff --git a/modules/cups/README.md b/modules/cups/README.md new file mode 100644 index 0000000..6141bbd --- /dev/null +++ b/modules/cups/README.md @@ -0,0 +1,84 @@ +# cups + +Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42 +phase 2 step 9). + +## Owns + +| what | where | +|---|---| +| the print scheduler | package `cups` | +| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` | +| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled | + +All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates) +and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and +the module declares none of them. + +## The printer's driver: none needed for the Brother + +Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04: + +| workstation | queue | device | prints through | driver package installed | +|---|---|---|---|---| +| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused | +| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused | +| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) | + +**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support, +with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue, +and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares +no driver, and the Brother needs nothing outside the official repositories. + +**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012, +with its own network backend. Like snapd, it waits for the mesh's package repository (research 027 +question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check +with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces +it and the question goes away. + +## Improves + +- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records. +- **No driver package that nothing uses.** The README's one-off step below removes them, once. +- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a + low one. It also answers why a queue stopped, without opening the printer's page. +- **The laptop has no default printer**, so a print without a named printer fails there. + `cups_default` sets one; it is the operator's choice, not declared. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account +print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another +account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the +account first. + +| tool | what | +|---|---| +| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) | +| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded | +| `cups_cancel` (a) | one job, or every job on a printer | +| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id | +| `cups_default` (r/a) | read the default, or set it | +| `cups_resume` (a) | enable a stopped printer and make it accept jobs | +| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered | + +## What changes when it is assigned + +Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its +dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the +mesh's; `cups-filters` is now declared explicitly. + +## The one-off step for the operator (ADR 0182) + +The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do +today), remove the unused vendor drivers, once: + +- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in + (`lib32-glibc`). +- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while + the Canon queue is used. + +## Leaves as found + +The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset), +`system-config-printer` on the desktop, and `cnijfilter-mg4200`. diff --git a/modules/cups/cmd/cups-tools/cups.go b/modules/cups/cmd/cups-tools/cups.go new file mode 100644 index 0000000..659516d --- /dev/null +++ b/modules/cups/cmd/cups-tools/cups.go @@ -0,0 +1,560 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`) + +func checkPrinter(p string) error { + if !queueName.MatchString(p) || strings.HasPrefix(p, "-") { + return fmt.Errorf("%q is not a printer queue's name", p) + } + return nil +} + +var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) +var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`) + +// optionsOf reads the print options object. +func optionsOf(args map[string]any) (map[string]string, error) { + v, ok := args["options"] + if !ok || v == nil { + return nil, nil + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("options must be an object of names to values") + } + out := map[string]string{} + for k, val := range m { + s, ok := val.(string) + if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) { + return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val) + } + out[k] = s + } + return out, nil +} + +// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes. +func lpoptionsOf(s string) map[string]string { + out := map[string]string{} + s = strings.TrimSpace(s) + i := 0 + for i < len(s) { + for i < len(s) && s[i] == ' ' { + i++ + } + start := i + for i < len(s) && s[i] != '=' && s[i] != ' ' { + i++ + } + key := s[start:i] + if i >= len(s) || s[i] != '=' { + if key != "" { + out[key] = "" + } + continue + } + i++ + var b strings.Builder + for i < len(s) && s[i] != ' ' { + switch s[i] { + case '\'': + i++ + for i < len(s) && s[i] != '\'' { + if s[i] == '\\' && i+1 < len(s) { + i++ + } + b.WriteByte(s[i]) + i++ + } + i++ + case '\\': + if i+1 < len(s) { + b.WriteByte(s[i+1]) + } + i += 2 + default: + b.WriteByte(s[i]) + i++ + } + } + out[key] = b.String() + } + return out +} + +// Marker is one supply the printer reports. +type Marker struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Level int `json:"level_percent"` + Low bool `json:"low"` +} + +// Printer is one queue. +type Printer struct { + Name string `json:"name"` + State string `json:"state"` + Enabled bool `json:"enabled"` + Accepting bool `json:"accepting"` + Default bool `json:"default"` + URI string `json:"uri"` + MakeModel string `json:"make_and_model"` + Driverless bool `json:"driverless"` + Shared bool `json:"shared"` + Reasons []string `json:"reasons"` + Markers []Marker `json:"markers"` + Since string `json:"since,omitempty"` + Message string `json:"message,omitempty"` +} + +// PrintersAnswer is what cups_printers answers. +type PrintersAnswer struct { + Scheduler string `json:"scheduler"` + Default string `json:"default,omitempty"` + Printers []Printer `json:"printers"` +} + +func lpstat(args ...string) (string, error) { + r, err := call(Cmd{Name: "lpstat", Args: args}) + if err != nil { + if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") { + return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr)) + } + // lpstat answers "No destinations added." with a non-zero status: that is no printers. + if strings.Contains(r.Stderr, "No destinations added") { + return "", nil + } + return "", err + } + return r.Stdout, nil +} + +func defaultPrinter() (string, error) { + out, err := lpstat("-d") + if err != nil { + return "", err + } + if _, after, ok := strings.Cut(out, "system default destination: "); ok { + return strings.TrimSpace(firstLine(after)), nil + } + return "", nil +} + +// Printers answers every queue with its state, device, model and supplies. +func Printers() (PrintersAnswer, error) { + out := PrintersAnswer{Printers: []Printer{}} + sched, err := lpstat("-r") + if err != nil { + return out, err + } + out.Scheduler = strings.TrimSpace(sched) + if out.Default, err = defaultPrinter(); err != nil { + return out, err + } + ps, err := lpstat("-p") + if err != nil { + return out, err + } + var cur *Printer + for _, l := range strings.Split(ps, "\n") { + if strings.HasPrefix(l, "printer ") { + f := strings.Fields(l) + if len(f) < 3 { + continue + } + out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}}) + cur = &out.Printers[len(out.Printers)-1] + rest := strings.Join(f[2:], " ") + switch { + case strings.HasPrefix(rest, "is idle"): + cur.State = "idle" + case strings.HasPrefix(rest, "now printing"): + cur.State = "printing" + default: + cur.State = "stopped" + } + // "disabled since …" (stopped), or "enabled since …" after the state. + cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since") + if _, since, found := strings.Cut(rest, " since "); found { + cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -") + } + continue + } + if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" { + cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l)) + } + } + acc, err := lpstat("-a") + if err != nil { + return out, err + } + accepting := map[string]bool{} + for _, l := range lines(acc) { + if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" { + accepting[f[0]] = true + } + } + for i := range out.Printers { + p := &out.Printers[i] + p.Accepting = accepting[p.Name] + p.Default = p.Name == out.Default + r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}}) + if err != nil { + return out, err + } + o := lpoptionsOf(r.Stdout) + p.URI = o["device-uri"] + p.MakeModel = o["printer-make-and-model"] + p.Driverless = driverless(p.MakeModel, p.URI) + p.Shared = o["printer-is-shared"] == "true" + for _, reason := range strings.Split(o["printer-state-reasons"], ",") { + if reason = strings.TrimSpace(reason); reason != "" && reason != "none" { + p.Reasons = append(p.Reasons, reason) + } + } + p.Markers = markersOf(o) + } + return out, nil +} + +// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a +// driverless URI. +func driverless(model, uri string) bool { + m := strings.ToLower(model) + return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") || + strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere") +} + +func markersOf(o map[string]string) []Marker { + split := func(k string) []string { + if o[k] == "" { + return nil + } + return strings.Split(o[k], ",") + } + names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types") + out := []Marker{} + for i, n := range names { + if i >= len(levels) { + break + } + level, err := strconv.Atoi(strings.TrimSpace(levels[i])) + if err != nil { + continue + } + m := Marker{Name: strings.TrimSpace(n), Level: level} + if i < len(types) { + m.Type = strings.TrimSpace(types[i]) + } + if i < len(lows) { + if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low { + m.Low = true + } + } + out = append(out, m) + } + return out +} + +// Job is one print job. +type Job struct { + ID string `json:"id"` + Printer string `json:"printer"` + User string `json:"user"` + Bytes int64 `json:"bytes"` + Submitted string `json:"submitted"` +} + +// Queue answers the jobs waiting, or the finished ones. +func Queue(printer string, completed bool, limit int) (map[string]any, error) { + args := []string{} + if completed { + args = append(args, "-W", "completed") + } + args = append(args, "-o") + if printer != "" { + if err := checkPrinter(printer); err != nil { + return nil, err + } + args = append(args, printer) + } + out, err := lpstat(args...) + if err != nil { + return nil, err + } + jobs := []Job{} + for _, l := range lines(out) { + f := strings.Fields(l) + if len(f) < 4 { + continue + } + i := strings.LastIndex(f[0], "-") + if i <= 0 { + continue + } + size, _ := strconv.ParseInt(f[2], 10, 64) + jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")}) + } + sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) }) + total := len(jobs) + if len(jobs) > limit { + jobs = jobs[:limit] + } + return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil +} + +func jobNumber(id string) int { + n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:]) + return n +} + +var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`) + +// refused says CUPS kept an act for its administrators. +func refused(r Result) bool { + s := r.Stderr + r.Stdout + return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed") +} + +// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account. +func asAccountThenRoot(c Cmd) (Result, bool, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, false, nil + } + if !refused(r) { + return r, false, failure(c, r) + } + c.Root = true + r, err := call(c) + return r, true, err +} + +// Cancel cancels one job, or every job on a printer. +func Cancel(job, printer string, all bool) (map[string]any, error) { + var c Cmd + switch { + case all: + if printer == "" { + return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered") + } + if err := checkPrinter(printer); err != nil { + return nil, err + } + c = Cmd{Name: "cancel", Args: []string{"-a", printer}} + case job != "": + if !jobID.MatchString(job) { + return nil, fmt.Errorf("%q is not a job id", job) + } + c = Cmd{Name: "cancel", Args: []string{job}} + default: + return nil, fmt.Errorf("give job, or printer with all") + } + _, escalated, err := asAccountThenRoot(c) + if err != nil { + return nil, err + } + return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil +} + +var requestID = regexp.MustCompile(`request id is (\S+)`) + +// statFile tells a regular file. Tests replace it. +var statFile = func(p string) error { + info, err := os.Stat(p) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", p) + } + return nil +} + +// Print sends a file to a printer. +func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) { + if !filepath.IsAbs(file) { + return nil, fmt.Errorf("file must be an absolute path, not %q", file) + } + if err := statFile(file); err != nil { + return nil, fmt.Errorf("cannot print %s: %v", file, err) + } + args := []string{} + if printer != "" { + if err := checkPrinter(printer); err != nil { + return nil, err + } + args = append(args, "-d", printer) + } + args = append(args, "-n", strconv.Itoa(copies)) + names := make([]string, 0, len(opts)) + for k := range opts { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + args = append(args, "-o", k+"="+opts[k]) + } + if title == "" { + title = filepath.Base(file) + } + args = append(args, "-t", title, "--", file) + r, err := call(Cmd{Name: "lp", Args: args}) + if err != nil { + if strings.Contains(r.Stderr, "No default destination") { + return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default") + } + return nil, err + } + m := requestID.FindStringSubmatch(r.Stdout) + if m == nil { + return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr)) + } + return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil +} + +// Default answers the default printer, or sets it. +func Default(printer string) (map[string]any, error) { + if printer == "" { + d, err := defaultPrinter() + if err != nil { + return nil, err + } + return map[string]any{"default": d}, nil + } + if err := checkPrinter(printer); err != nil { + return nil, err + } + was, err := defaultPrinter() + if err != nil { + return nil, err + } + if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil { + return nil, err + } + return map[string]any{"default": printer, "was": was}, nil +} + +// Resume enables a printer and makes it accept jobs. +func Resume(printer string) (map[string]any, error) { + if err := checkPrinter(printer); err != nil { + return nil, err + } + for _, c := range []string{"cupsenable", "cupsaccept"} { + if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil { + return nil, err + } + } + return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil +} + +// DriverPackage is a package that brings filters or backends. +type DriverPackage struct { + Package string `json:"package"` + Version string `json:"version"` + Foreign bool `json:"foreign"` +} + +// Model is one driver model CUPS offers. +type Model struct { + PPD string `json:"ppd"` + Description string `json:"description"` +} + +// QueueDriver is how one queue prints. +type QueueDriver struct { + Printer string `json:"printer"` + MakeModel string `json:"make_and_model"` + Driverless bool `json:"driverless"` + URI string `json:"uri"` +} + +// DriversAnswer is what cups_drivers answers. +type DriversAnswer struct { + Queues []QueueDriver `json:"queues"` + Packages []DriverPackage `json:"driver_packages"` + Models []Model `json:"models"` + Matched int `json:"models_matched"` + Findings []string `json:"findings"` +} + +// driverDirs are where drivers put what CUPS runs. +var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"} + +// basePackages bring CUPS's own filters and backends, not a printer's driver. +var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false} + +// Drivers answers how each queue prints and which packages bring drivers. +func Drivers(match string, limit int) (DriversAnswer, error) { + out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}} + ps, err := Printers() + if err != nil { + return out, err + } + for _, p := range ps.Printers { + out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI}) + } + r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)}) + if r.Error != "" { + return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r) + } + seen := map[string]bool{} + for _, l := range lines(r.Stdout) { + if _, after, ok := strings.Cut(l, " is owned by "); ok { + f := strings.Fields(after) + if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] { + seen[f[0]] = true + out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]}) + } + } + } + if len(out.Packages) > 0 { + r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}}) + foreign := map[string]bool{} + for _, l := range lines(r.Stdout) { + foreign[strings.TrimSpace(l)] = true + } + for i := range out.Packages { + out.Packages[i].Foreign = foreign[out.Packages[i].Package] + } + } + sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package }) + m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}}) + if err != nil { + return out, err + } + for _, l := range lines(m.Stdout) { + ppd, desc, _ := strings.Cut(l, " ") + if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) { + continue + } + out.Matched++ + if len(out.Models) < limit { + out.Models = append(out.Models, Model{PPD: ppd, Description: desc}) + } + } + // Which driver packages no queue prints through. + allDriverless := len(out.Queues) > 0 + for _, q := range out.Queues { + allDriverless = allDriverless && q.Driverless + } + for _, p := range out.Packages { + if p.Foreign { + out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories") + } + if allDriverless { + out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package) + } + } + return out, nil +} diff --git a/modules/cups/cmd/cups-tools/cups_test.go b/modules/cups/cmd/cups-tools/cups_test.go new file mode 100644 index 0000000..d58561a --- /dev/null +++ b/modules/cups/cmd/cups-tools/cups_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "cups") + if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" { + t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got) + } + s := m.services() + for _, u := range []string{"cups.socket", "cups.service"} { + if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" { + t.Errorf("%s: %v", u, s[u]) + } + } + for _, r := range m.Resources { + if r["type"] == "file" { + t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"]) + } + } +} + +// The desktop's two queues on 2026-10-04. +func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake { + return using(t, func(line string, c Cmd) Result { + if more != nil { + if r, handled := more(line, c); handled { + return r + } + } + switch line { + case "lpstat -r": + return ok("scheduler is running\n") + case "lpstat -d": + return ok("system default destination: Brother_MFC_Novox\n") + case "lpstat -p": + return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" + + "printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n") + case "lpstat -a": + return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n") + case "lpoptions -p Brother_MFC_Novox": + return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`) + case "lpoptions -p Kanjuro": + return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`) + } + return Result{Status: 9, Stderr: "unexpected " + line} + }) +} + +func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) { + theDesktop(t, nil) + got, err := Printers() + if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" { + t.Fatalf("%+v %v", got, err) + } + b, k := got.Printers[0], got.Printers[1] + if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 { + t.Errorf("%+v", b) + } + if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low { + t.Errorf("markers %+v", b.Markers) + } + if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" { + t.Errorf("%+v", k) + } +} + +func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if line == "lpstat -r" { + return ok("scheduler is running\n") + } + return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"} + }) + got, err := Printers() + if err != nil || len(got.Printers) != 0 { + t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} }) + if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") { + t.Fatalf("%v", err) + } +} + +func TestLpoptionsQuotingIsRead(t *testing.T) { + o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`) + if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" { + t.Errorf("%v", o) + } +} + +func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) { + f := using(t, func(string, Cmd) Result { + return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n") + }) + got, err := Queue("Brother", true, 2) + jobs := got["jobs"].([]Job) + if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "lpstat -W completed -o Brother" { + t.Errorf("%v", f.lines()) + } + if _, err := Queue("-h", false, 5); err == nil { + t.Error("an option as a printer") + } +} + +func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if !strings.HasPrefix(line, "sudo") { + return Result{Status: 1, Stderr: "cancel: Forbidden\n"} + } + return ok("") + }) + got, err := Cancel("Brother-12", "", false) + if err != nil || got["as_root"] != true { + t.Fatalf("%v %v", got, err) + } + if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" { + t.Errorf("%v", f.lines()) + } + f = using(t, func(string, Cmd) Result { return ok("") }) + if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" { + t.Fatalf("%v %v %v", got, err, f.lines()) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} }) + if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") { + t.Errorf("a failure that is not a refusal is not retried as root: %v", err) + } + for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} { + if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) { + was := statFile + defer func() { statFile = was }() + statFile = func(p string) error { + if p == "/home/op/doc.pdf" { + return nil + } + return errString("no such file") + } + f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") }) + got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "") + if err != nil || got["job"] != "Brother-13" { + t.Fatalf("%v %v", got, err) + } + if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" { + t.Errorf("%s", l) + } + if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil { + t.Error("a relative file") + } + if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil { + t.Error("a missing file") + } + if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil { + t.Error("an option value with a space") + } + if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil { + t.Error("an option name that is an option") + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} }) + if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") { + t.Errorf("%v", err) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func TestDefaultReadsAndSetsThroughSudo(t *testing.T) { + f := theDesktop(t, func(line string, c Cmd) (Result, bool) { + if strings.HasPrefix(line, "sudo -n lpadmin") { + return ok(""), true + } + return Result{}, false + }) + got, err := Default("") + if err != nil || got["default"] != "Brother_MFC_Novox" { + t.Fatalf("%v %v", got, err) + } + got, err = Default("Kanjuro") + if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" { + t.Fatalf("%v %v", got, err) + } + if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" { + t.Errorf("%v", l) + } +} + +func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok("") }) + if _, err := Resume("Kanjuro"); err != nil { + t.Fatal(err) + } + if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" { + t.Errorf("%v", f.lines()) + } +} + +func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) { + theDesktop(t, func(line string, c Cmd) (Result, bool) { + switch { + case strings.HasPrefix(line, "pacman -Qo"): + return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true + case line == "pacman -Qqm": + return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true + case line == "lpinfo -m": + return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true + } + return Result{}, false + }) + got, err := Drivers("canon", 10) + if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" { + t.Fatalf("%+v %v", got, err) + } + if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" { + t.Errorf("%+v", got.Packages) + } + all := strings.Join(got.Findings, ";") + if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") { + t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all) + } +} diff --git a/modules/cups/cmd/cups-tools/kit.go b/modules/cups/cmd/cups-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/cups/cmd/cups-tools/kit.go @@ -0,0 +1,352 @@ +package main + +// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd, +// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it +// keeps, and names a failure. A module is built from its own directory, so the file is copied rather +// than shared; a change to one copy is made to all eight. +// +// The rules it holds (novox/hq research 026/05, to-be 38 WP4): +// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that +// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such; +// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it +// started when it takes longer; +// - each stream is kept to 256 KiB, and the answer says when it was cut; +// - a failure is an error with what went wrong in it, never an empty answer. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds every command is held to. +const ( + CallTimeout = 20 * time.Second + MostOutput = 256 << 10 +) + +// Cmd is one command a tool runs. +type Cmd struct { + Name string + Args []string + // Stdin is written to the command's standard input when not empty. + Stdin string + // Env is added to this process's own environment. + Env []string + // Root says the command needs root: it is run through `sudo -n` when this process is not root. + Root bool + // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer. + Timeout time.Duration + // Detached is for a program that forks a child which outlives it, as xclip does to keep the + // selection: its streams go to files, because a pipe the child inherits would hold the call open + // until the child exits. + Detached bool +} + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Status int `json:"status"` + // Error is why it did not run to an answer: "not-found" when the program is not there, + // "timeout" when it was ended for taking too long, else the spawn error. + Error string `json:"error,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// Runner runs a command. Tests replace it; nothing else does. +type Runner func(Cmd) Result + +var ( + run Runner = execRun + euid = os.Geteuid +) + +// argv is the command as it is run: through sudo without a prompt when it needs root and this +// process is not root. +func argv(c Cmd) (string, []string) { + if c.Root && euid() != 0 { + return "sudo", append([]string{"-n", c.Name}, c.Args...) + } + return c.Name, c.Args +} + +// bounded keeps the first MostOutput bytes written to it and notes that more came. +type bounded struct { + b bytes.Buffer + cut bool +} + +func (w *bounded) Write(p []byte) (int, error) { + room := MostOutput - w.b.Len() + if room <= 0 { + w.cut = w.cut || len(p) > 0 + return len(p), nil + } + if len(p) > room { + w.b.Write(p[:room]) + w.cut = true + return len(p), nil + } + return w.b.Write(p) +} + +func execRun(c Cmd) Result { + timeout := c.Timeout + if timeout <= 0 { + timeout = CallTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name, args := argv(c) + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...) + if !c.Detached { + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + } + cmd.WaitDelay = 2 * time.Second + if c.Stdin != "" { + cmd.Stdin = strings.NewReader(c.Stdin) + } + var out, errs bounded + var outFile, errFile *os.File + if c.Detached { + var err error + if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(outFile.Name()) + defer outFile.Close() + if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(errFile.Name()) + defer errFile.Close() + cmd.Stdout, cmd.Stderr = outFile, errFile + } else { + cmd.Stdout, cmd.Stderr = &out, &errs + } + err := cmd.Run() + if c.Detached { + for _, f := range []struct { + file *os.File + into *bounded + }{{outFile, &out}, {errFile, &errs}} { + if _, e := f.file.Seek(0, io.SeekStart); e == nil { + _, _ = io.Copy(f.into, f.file) + } + } + } + r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + r.Status, r.Error = 124, "timeout" + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist): + r.Status, r.Error = 127, "not-found" + case errors.As(err, &exit): + r.Status = exit.ExitCode() + default: + r.Status, r.Error = 127, err.Error() + } + return r +} + +// call runs a command and answers its result, or an error naming what went wrong. +func call(c Cmd) (Result, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, nil + } + return r, failure(c, r) +} + +// failure names how a command failed: not installed, refused escalation, too slow, or its exit +// status with the end of what it said. +func failure(c Cmd, r Result) error { + program, _ := argv(c) + switch { + case r.Error == "not-found" && program == "sudo": + return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name) + case r.Error == "not-found": + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case r.Error == "timeout": + limit := c.Timeout + if limit <= 0 { + limit = CallTimeout + } + return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit) + case r.Error != "": + return fmt.Errorf("%s did not run: %s", c.Name, r.Error) + case program == "sudo" && strings.Contains(r.Stderr, "command not found"): + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"): + return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)", + c.Name, firstLine(r.Stderr)) + } + said := tail(strings.TrimSpace(r.Stderr), 2000) + if said == "" { + said = tail(strings.TrimSpace(r.Stdout), 2000) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +// lines are a command's output lines, blank ones dropped. +func lines(s string) []string { + out := []string{} + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, strings.TrimRight(l, "\r")) + } + } + return out +} + +// Arguments, read the way a tool's JSON arguments arrive. + +func text(args map[string]any, key string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return "", fmt.Errorf("%s is required", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("%s must not be empty", key) + } + return s, nil +} + +func optText(args map[string]any, key, def string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return def, nil + } + return s, nil +} + +// optWhole reads a whole number, defaulted, refused below least and held to most. +func optWhole(args map[string]any, key string, def, least, most int) (int, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s must be a number", key) + } + } + if f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +func optFlag(args map[string]any, key string, def bool) (bool, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +func optList(args map[string]any, key string) ([]string, error) { + v, ok := args[key] + if !ok || v == nil { + return nil, nil + } + items, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", key) + } + out := make([]string, 0, len(items)) + for _, it := range items { + s, ok := it.(string) + if !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("%s must be a list of non-empty strings", key) + } + out = append(out, s) + } + return out, nil +} + +// oneOf refuses a value outside a closed set. +func oneOf(key, value string, allowed ...string) error { + for _, a := range allowed { + if value == a { + return nil + } + } + return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value) +} + +// plainName refuses a name that could be read as an option or carries a path or a space: package, +// snap, application and printer names never do. +func plainName(key, value string) error { + if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") { + return fmt.Errorf("%s %q is not a plain name", key, value) + } + return nil +} diff --git a/modules/cups/cmd/cups-tools/kit_test.go b/modules/cups/cmd/cups-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/cups/cmd/cups-tools/kit_test.go @@ -0,0 +1,147 @@ +package main + +// Tests of kit.go, the same in each workstation module. + +import ( + "strings" + "testing" + "time" +) + +// fake records the commands asked and answers each from a function of the command line. +type fake struct { + asked []Cmd + answer func(line string, c Cmd) Result +} + +func (f *fake) runner() Runner { + return func(c Cmd) Result { + f.asked = append(f.asked, c) + name, args := argv(c) + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if f.answer == nil { + return Result{} + } + return f.answer(line, c) + } +} + +func (f *fake) lines() []string { + out := []string{} + for _, c := range f.asked { + name, args := argv(c) + out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " "))) + } + return out +} + +// using installs a fake runner and a non-root uid for one test. +func using(t *testing.T, answer func(line string, c Cmd) Result) *fake { + t.Helper() + f := &fake{answer: answer} + wasRun, wasUID := run, euid + run, euid = f.runner(), func() int { return 1000 } + t.Cleanup(func() { run, euid = wasRun, wasUID }) + return f +} + +func ok(stdout string) Result { return Result{Stdout: stdout} } + +func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" { + t.Fatalf("not root: %s %v", name, args) + } + if name, _ := argv(Cmd{Name: "x"}); name != "x" { + t.Fatalf("a read is run as the account: %s", name) + } + euid = func() int { return 0 } + if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" { + t.Fatalf("as root no sudo: %s", name) + } +} + +func TestKitAFailureIsNamedByHowItFailed(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + cases := []struct { + c Cmd + r Result + want string + }{ + {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"}, + {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"}, + {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"}, + {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"}, + } + for _, k := range cases { + err := failure(k.c, k.r) + if err == nil || !strings.Contains(err.Error(), k.want) { + t.Errorf("%+v: %v, want %q", k.r, err, k.want) + } + } +} + +func TestKitOutputIsBoundedAndSaysSo(t *testing.T) { + var w bounded + big := strings.Repeat("a", MostOutput+10) + n, _ := w.Write([]byte(big)) + if n != len(big) || w.b.Len() != MostOutput || !w.cut { + t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut) + } +} + +func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) { + r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}}) + if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("%+v", r) + } + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond}) + if r.Error != "timeout" { + t.Fatalf("a slow command: %+v", r) + } + r = execRun(Cmd{Name: "no-such-program-anywhere"}) + if r.Error != "not-found" { + t.Fatalf("a missing program: %+v", r) + } + r = execRun(Cmd{Name: "cat", Stdin: "given"}) + if r.Stdout != "given" { + t.Fatalf("stdin: %+v", r) + } + start := time.Now() + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true}) + if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second { + t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start)) + } +} + +func TestKitArgumentsAreReadStrictly(t *testing.T) { + args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if _, err := text(args, "missing"); err == nil { + t.Error("a missing required string") + } + if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 { + t.Errorf("held to most: %d", n) + } + if _, err := optWhole(args, "n", 1, 6, 9); err == nil { + t.Error("below least") + } + if _, err := optWhole(args, "f", 1, 0, 9); err == nil { + t.Error("a fraction") + } + if l, _ := optList(args, "l"); len(l) != 2 { + t.Errorf("list: %v", l) + } + if b, _ := optFlag(args, "b", false); !b { + t.Error("flag") + } + if err := plainName("name", "--all"); err == nil { + t.Error("an option as a name") + } +} diff --git a/modules/cups/cmd/cups-tools/main.go b/modules/cups/cmd/cups-tools/main.go new file mode 100644 index 0000000..93cd848 --- /dev/null +++ b/modules/cups/cmd/cups-tools/main.go @@ -0,0 +1,177 @@ +// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and +// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime +// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for +// its administrators goes through `sudo -n`. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "lpstat": "the cups package, which this module installs", + "lpoptions": "the cups package, which this module installs", + "lp": "the cups package, which this module installs", + "cancel": "the cups package, which this module installs", + "lpadmin": "the cups package, which this module installs", + "lpinfo": "the cups package, which this module installs", + "cupsenable": "the cups package, which this module installs", + "cupsaccept": "the cups package, which this module installs", + "pacman": "this is not an Arch machine", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "cups_printers", + Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " + + "address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " + + "supply levels where the printer reports them. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Printers() }, + }, + { + Name: "cups_queue", + Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " + + "Each with its id, printer, owner, size and when it was submitted. (r)", + Input: map[string]any{ + "printer": printerArg, + "completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"}, + "limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"}, + }, + Run: func(args map[string]any) (any, error) { + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + done, err := optFlag(args, "completed", false) + if err != nil { + return nil, err + } + limit, err := optWhole(args, "limit", 50, 1, 500) + if err != nil { + return nil, err + } + return Queue(p, done, limit) + }, + }, + { + Name: "cups_cancel", + Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " + + "account's job is cancelled through sudo -n. (a)", + Input: map[string]any{ + "job": map[string]any{"type": "string", "description": "the job id"}, + "printer": printerArg, + "all": map[string]any{"type": "boolean", "description": "every job on printer"}, + }, + Run: func(args map[string]any) (any, error) { + job, err := optText(args, "job", "") + if err != nil { + return nil, err + } + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + all, err := optFlag(args, "all", false) + if err != nil { + return nil, err + } + return Cancel(job, p, all) + }, + }, + { + Name: "cups_print", + Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " + + "sides=two-sided-long-edge or media=A4. Answers the job id. (a)", + Input: map[string]any{ + "file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"}, + "printer": printerArg, + "copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"}, + "options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"}, + "title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"}, + }, + Run: func(args map[string]any) (any, error) { + file, err := text(args, "file") + if err != nil { + return nil, err + } + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + copies, err := optWhole(args, "copies", 1, 1, 99) + if err != nil { + return nil, err + } + opts, err := optionsOf(args) + if err != nil { + return nil, err + } + title, err := optText(args, "title", "") + if err != nil { + return nil, err + } + return Print(file, p, copies, opts, title) + }, + }, + { + Name: "cups_default", + Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " + + "The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)", + Input: map[string]any{"printer": printerArg}, + Run: func(args map[string]any) (any, error) { + p, err := optText(args, "printer", "") + if err != nil { + return nil, err + } + return Default(p) + }, + }, + { + Name: "cups_resume", + Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)", + Input: map[string]any{"printer": printerArg}, + Run: func(args map[string]any) (any, error) { + p, err := text(args, "printer") + if err != nil { + return nil, err + } + return Resume(p) + }, + }, + { + Name: "cups_drivers", + Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " + + "and backends, which of them are from outside the official repositories, and the driver models CUPS " + + "offers, filtered by match. (r)", + Input: map[string]any{ + "match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"}, + "limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"}, + }, + Run: func(args map[string]any) (any, error) { + match, err := optText(args, "match", "") + if err != nil { + return nil, err + } + limit, err := optWhole(args, "limit", 50, 0, 1000) + if err != nil { + return nil, err + } + return Drivers(match, limit) + }, + }, + } +} diff --git a/modules/cups/cmd/cups-tools/manifest_kit_test.go b/modules/cups/cmd/cups-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/cups/cmd/cups-tools/manifest_kit_test.go @@ -0,0 +1,107 @@ +package main + +// manifest_kit_test.go is the same file in each workstation module: it reads the module's +// definition so the module's own tests can hold it to what it says. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + return nil +} + +// packages are the packages the module installs, sorted. +func (m manifest) packages() []string { + out := []string{} + for _, r := range m.Resources { + if r["type"] == "package" && r["absent"] != true { + out = append(out, r["package"].(string)) + } + } + sort.Strings(out) + return out +} + +// services are the units the module declares, by unit name. +func (m manifest) services() map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if r["type"] == "service" { + out[r["unit"].(string)] = r + } + } + return out +} + +// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered +// is listed and nothing else, each named _…, and the artifact builds this command. +func holdsTheBundle(t *testing.T, m manifest, prefix string) { + t.Helper() + registered := []string{} + for _, tool := range tools() { + registered = append(registered, tool.Name) + if !strings.HasPrefix(tool.Name, prefix+"_") { + t.Errorf("tool %s is not named %s_…", tool.Name, prefix) + } + if tool.Description == "" || tool.Run == nil || tool.Input == nil { + t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name) + } + } + if strings.Join(registered, ",") != strings.Join(m.Tools, ",") { + t.Errorf("registered %v, listed %v", registered, m.Tools) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("one artifact, got %d", len(m.Build.Artifacts)) + } + cwd, _ := os.Getwd() + binary := filepath.Base(cwd) + a := m.Build.Artifacts[0] + want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary} + for k, v := range want { + if a[k] != v { + t.Errorf("artifact %s = %v, want %v", k, a[k], v) + } + } + if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary { + t.Errorf("artifact loads %v, want [%s]", a["loads"], binary) + } + if m.Claims != nil || m.Seats != nil { + t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats) + } +} diff --git a/modules/cups/go.mod b/modules/cups/go.mod new file mode 100644 index 0000000..56eba25 --- /dev/null +++ b/modules/cups/go.mod @@ -0,0 +1,5 @@ +module cups + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/cups/go.sum b/modules/cups/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/cups/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/cups/module.json b/modules/cups/module.json new file mode 100644 index 0000000..01a4822 --- /dev/null +++ b/modules/cups/module.json @@ -0,0 +1,58 @@ +{ + "module": "cups", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "cups_printers", + "cups_queue", + "cups_cancel", + "cups_print", + "cups_default", + "cups_resume", + "cups_drivers" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "cups" + }, + { + "id": "driverless", + "type": "package", + "package": "cups-filters" + }, + { + "id": "socket", + "type": "service", + "unit": "cups.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "scheduler", + "type": "service", + "unit": "cups.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/cups-tools", + "binary": "cups-tools", + "loads": [ + "cups-tools" + ] + } + ] + } +}