Merge pull request 'The brother module: one module for the Brother printer, holding the scanner and printer seats, scans kept in its bucket and printing over the broker (issue 1453, ADR 0467, ADR 0468)' (#384) from feat/brother into main

This commit was merged in pull request #384.
This commit is contained in:
2026-10-20 19:48:06 +00:00
59 changed files with 4936 additions and 570 deletions
+1
View File
@@ -24,3 +24,4 @@ modules/mesh-files/cmd/mesh-files/mesh-files
# The provisioner client built in place (issue 671).
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
modules/brother/cmd/brother/brother
+127
View File
@@ -0,0 +1,127 @@
# brother — the Brother multifunction device: scan, printer state, admin pages
One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the
network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`,
`status`, `list`, `document`) and `printer` (verbs `status`, `settings`, `print`, `upload`; `restart` follows in a later round). It replaces the
`scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue.
## The scanner seat
Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that
speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF
in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called:
the module runs no process of its own, and it runs as the node's operator account.
## What a scan does
1. Asks the scanner for its glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once.
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why.
## Config
| key | meaning | default |
|---|---|---|
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none |
| `printer-certificate` | the SHA-256 fingerprint of the one certificate the admin page may serve, `AA:BB:...` (required, trusted) | none |
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
| `color-mode` | `color` or `gray` | `color` |
| `crop` | cut the document out of the lid's white | true |
The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes
from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every scanner verb says which and
writes and asks nothing; `printer` and `printer-certificate` are the printer verbs' to say.
The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code
makes it (mode 0755) when it files the first scan.
## Tools
| verb (tool) | does |
|---|---|
| `scanner.scan` | asks the device process to scan, crop, keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` |
| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 |
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
## The printer seat
| verb (tool) | does |
|---|---|
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
| `printer.print` | prints a kept scan (`object`) or a completed upload (`upload`) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state |
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
### Trust and the password
The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate
whose SHA-256 fingerprint is `printer-certificate` and refuses any other, in the handshake, so no password is sent
to it; the error names both fingerprints. Get the fingerprint from the device's certificate page, and give it as 32
upper-case hex pairs joined by colons.
The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it
at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the
module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of
mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names
it for the device process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that
no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret
is not given, and the printer is not contacted.
### Documents in parts
The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module
cuts and the most it accepts, as ADR 0468 clause 3 says),
counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket
(`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode
0600), with the S3 client mesh-mail uses; a scan is kept once as `<sha256>.pdf` and an upload as `uploads/<id>.<ext>`.
### The device process
`brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a
seat, and does all the device and bucket work: the scan (and `scan.added`, emitted by it with the object's key and no
path, since filing in the scan folder is the tool's), the documents, the printing, and the admin pages. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and
writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's
title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the
page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three
deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can
reach its own files; both are the choices that let its account read what it needs. Its health is its unit.
The login reads the form each time: the device generates the password field's name, so the code takes the name from
the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is
the login page again. The logout follows a logout link or form on the page; a page with none is said in the answer
(`logout`), and the session is left to expire on the device.
## The event
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
and `scanner.list` only reads.
## Tests
The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served
by a test HTTP server, the captured IPP exchange (`testdata/ipp`) replayed, and the captured login page
(`testdata/admin/login-page.html`) served by a test TLS server with its own certificate. The pages behind the login
are synthetic (`testdata/admin/synthetic-*.html`) until round 2 captures the real ones. No test reaches the real
device.
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
)
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
type Admin struct {
Host string
Password string
HTTP *http.Client
}
// loginPath is a page that needs a login: asking for it is answered by the login page.
const loginPath = "/general/status.html"
// maxPage bounds a page of the admin site.
const maxPage = 4 << 20
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
// cookie jar for the session.
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &Admin{
Host: host,
Password: password,
HTTP: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
Transport: &http.Transport{
TLSClientConfig: pinnedTLS(fingerprint),
DisableKeepAlives: true,
},
},
}, nil
}
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
var req *http.Request
var err error
if form != nil {
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
}
if err != nil {
return nil, nil, err
}
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
if err != nil {
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
}
if len(buf) > maxPage {
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
}
if resp.StatusCode >= 400 {
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
}
return buf, resp.Request.URL, nil
}
// passwordForm is the form of a page that holds a password field, or nil.
func passwordForm(p Page) (*Form, *Field) {
for i := range p.Forms {
for j := range p.Forms[i].Fields {
if p.Forms[i].Fields[j].Type == "password" {
return &p.Forms[i], &p.Forms[i].Fields[j]
}
}
}
return nil, nil
}
// Login asks for a page that needs one, reads the login form it is answered with, and posts the form's own
// fields with the password in the field the form names. A login is failed when the answer is a login page
// again.
func (a *Admin) Login(ctx context.Context) error {
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginPath), nil)
if err != nil {
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
}
form, pw := passwordForm(ParsePage(body))
if form == nil {
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", loginPath)
}
target, err := at.Parse(form.Action)
if err != nil {
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
}
if target.Host != a.Host {
return fmt.Errorf("the login form posts to %s, not to the printer %s: the password is not sent", target.Host, a.Host)
}
vals := url.Values{}
for _, f := range form.Fields {
if f.Type == "hidden" {
vals.Set(f.Name, f.Value)
}
}
vals.Set(pw.Name, a.Password)
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
if err != nil {
return fmt.Errorf("the login was not answered: %w", err)
}
if again, _ := passwordForm(ParsePage(answer)); again != nil {
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
}
return nil
}
// Get is a page of the admin site, after a login.
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
}
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
// none leaves the session to expire, and the answer says so.
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
p := ParsePage(page)
isLogout := func(s string) bool {
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
}
for _, f := range p.Forms {
if !isLogout(f.Action) {
continue
}
target, err := at.Parse(f.Action)
if err != nil || target.Host != a.Host {
continue
}
vals := url.Values{}
for _, fl := range f.Fields {
if fl.Type == "hidden" {
vals.Set(fl.Name, fl.Value)
}
}
method := http.MethodPost
var send url.Values = vals
if f.Method == "get" {
method, send = http.MethodGet, nil
target.RawQuery = vals.Encode()
}
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
for _, l := range p.Links {
if !isLogout(l.Href) && !isLogout(l.Text) {
continue
}
target, err := at.Parse(l.Href)
if err != nil || target.Host != a.Host {
continue
}
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
return "no logout link or form on the page: the session is left to expire on the printer"
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one
// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test
// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/
// synthetic-*.html): the real ones arrive in round 2 of issue 1453.
type fakePrinter struct {
*httptest.Server
field string // the password field's name the login form gives
password string
mu sync.Mutex
conns int // connections opened to it
requests []string // method and path of every request
posted []url.Values // every form body posted
session string // the cookie value of the one logged-in session
loggedIn map[string]bool // cookie values that are logged in
pages map[string]string // path -> body behind the login
}
func adminFixture(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "admin", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
func newFakePrinter(t *testing.T, field, password string) *fakePrinter {
t.Helper()
f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}}
login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field)
synthetic := adminFixture(t, "synthetic-status.html")
f.pages["/general/status.html"] = synthetic
f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html")
authed := func(r *http.Request) bool {
c, err := r.Cookie("session")
return err == nil && f.loggedIn[c.Value]
}
mux := http.NewServeMux()
mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(login))
})
mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodPost {
if authed(r) {
w.Write([]byte(synthetic))
return
}
w.Write([]byte(login))
return
}
r.ParseForm()
f.posted = append(f.posted, r.PostForm)
if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" {
w.Write([]byte(login))
return
}
f.session = "s" + field
f.loggedIn[f.session] = true
http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"})
w.Write([]byte(synthetic))
})
mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if c, err := r.Cookie("session"); err == nil {
delete(f.loggedIn, c.Value)
}
w.Write([]byte(login))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
body, ok := f.pages[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
if !authed(r) {
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
w.Write([]byte(body))
})
f.Server = httptest.NewUnstartedServer(mux)
f.Config.ConnState = func(_ net.Conn, st http.ConnState) {
if st == http.StateNew {
f.mu.Lock()
f.conns++
f.mu.Unlock()
}
}
f.StartTLS()
t.Cleanup(f.Close)
return f
}
func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") }
// print is the fingerprint of the certificate the server serves.
func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) }
func (f *fakePrinter) connsNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.conns
}
func (f *fakePrinter) loggedInNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.loggedIn)
}
// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both
// fingerprints, and the server saw no request at all, so no password was sent.
func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
other := strings.Repeat("AA:", 31) + "AA"
a, err := NewAdmin(f.host(), other, "hunter2")
if err != nil {
t.Fatal(err)
}
err = a.Login(context.Background())
if err == nil {
t.Fatal("a certificate that is not the pinned one was accepted")
}
for _, want := range []string{f.print(), other} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name the fingerprint %s", err, want)
}
}
if len(f.requests) != 0 || len(f.posted) != 0 {
t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted)
}
}
// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits.
func TestThePinnedCertificateIsAccepted(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2")
if err := a.Login(context.Background()); err != nil {
t.Fatal(err)
}
}
// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes
// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with.
func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) {
for _, name := range []string{"B1891", "Zq77x"} {
f := newFakePrinter(t, name, "s3cret pass")
a, _ := NewAdmin(f.host(), f.print(), "s3cret pass")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatalf("field %s: %v", name, err)
}
if len(f.posted) != 1 {
t.Fatalf("field %s: %d forms posted", name, len(f.posted))
}
if got := f.posted[0].Get(name); got != "s3cret pass" {
t.Errorf("field %s: the password field holds %q", name, got)
}
if f.posted[0].Get("loginurl") != "/general/status.html" {
t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl"))
}
if len(f.posted[0]) != 2 {
t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0])
}
body, _, err := a.Get(ctx, "/general/other.html")
if err != nil || !strings.Contains(string(body), "Synthetic settings") {
t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body)
}
}
}
// 1453/AC4: a wrong password is a failed login, said so: the login page comes back.
func TestAWrongPasswordIsAFailedLogin(t *testing.T) {
f := newFakePrinter(t, "B1891", "right")
a, _ := NewAdmin(f.host(), f.print(), "wrong")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("error = %v, want a failed login", err)
}
if f.loggedInNow() != 0 {
t.Errorf("a session was opened")
}
}
// 1453: the logout link on a page ends the session; a page with none says so.
func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
a, _ := NewAdmin(f.host(), f.print(), "pw")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatal(err)
}
body, at, err := a.Get(ctx, "/general/status.html")
if err != nil {
t.Fatal(err)
}
if got := a.Logout(ctx, body, at); got != "logged out" {
t.Errorf("logout = %q", got)
}
if f.loggedInNow() != 0 {
t.Errorf("the session is still open")
}
if got := a.Logout(ctx, []byte("<html><body>nothing</body></html>"), at); !strings.Contains(got, "no logout") {
t.Errorf("logout without a link = %q", got)
}
}
// 1453: the module never sends the password to a host other than the printer, even if the form says so.
func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) {
// A login form whose action names another host.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<form method="post" action="https://elsewhere.example/x"><input type="password" name="p"/></form>`))
}))
srv.TLS = &tls.Config{}
srv.StartTLS()
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "not sent") {
t.Fatalf("error = %v", err)
}
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"math"
"time"
)
// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of
// the platen's white and built into a one-page PDF. Keeping it in the bucket and saying scan.added are the
// device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go).
func mm(pixels, dpi int) float64 {
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10
}
// Capture is a scanned, cropped page as a PDF, with what the event says of it.
type Capture struct {
PDF []byte
SHA256 string
WidthMM float64
HeightMM float64
Resolution int
Cropped bool
At time.Time
}
// Capture scans the glass, crops and builds the PDF. Nothing is kept and nothing is said here; an error says why
// the scanner gave no page. The resolution is the image's own, never the one asked (1440/AC2).
func (s *Service) Capture(ctx context.Context) (Capture, error) {
at := s.Now()
scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode)
if err != nil {
return Capture{}, err
}
dpi, err := Density(scan)
if err != nil {
return Capture{}, err
}
cut, err := Crop(scan, s.Settings.Crop)
if err != nil {
return Capture{}, err
}
pdf, err := PDF(cut.JPEG, dpi)
if err != nil {
return Capture{}, err
}
return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(cut.Width, dpi), HeightMM: mm(cut.Height, dpi),
Resolution: dpi, Cropped: cut.Cropped, At: at}, nil
}
// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image,
// and no path, since filing in a folder is the tool's, after the event.
func (c Capture) Body(node, colorMode string) map[string]any {
return map[string]any{
"object": c.SHA256 + ".pdf",
"node": node,
"bytes": len(c.PDF),
"sha256": c.SHA256,
"width-mm": c.WidthMM,
"height-mm": c.HeightMM,
"resolution": c.Resolution,
"color-mode": colorMode,
"cropped": c.Cropped,
"scanned-at": c.At.UTC().Format(time.RFC3339),
}
}
@@ -0,0 +1,70 @@
package main
// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the device process
// reads it, as its own account, brother-device. The build seat runs every test under one account, so no
// permission test can show the split; this is the file-level proxy mesh-mail's secret_boundary_test.go uses
// instead: an AST check that the tools' files import no password-reading package and name no password file.
import (
"go/parser"
"go/token"
"os"
"strings"
"testing"
)
const secretPackage = "brother/cmd/brother/internal/secret"
// toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings'
// ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go.
var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go"}
// 1453: none of the tools' files imports the password-reading package or names the password's file.
func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) {
for _, file := range toolFiles {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
t.Errorf("%s imports %s, which only the device process may (ADR 0436 §12)", file, secretPackage)
}
}
src, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, bad := range []string{`Getenv("BROTHER_ADMIN_PASSWORD")`, `Getenv("BROTHER_STORE")`, "ReadStoreFacts", "NewS3", "StoreFacts"} {
if strings.Contains(string(src), bad) {
t.Errorf("%s names %s: the password's and the bucket key's files are the device process's alone (ADR 0468 clause 1)", file, bad)
}
}
}
}
// 1453: the device process does read it through the package, and the package is the one place a password file is
// opened.
func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "deviceproc.go", nil, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
found := false
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
found = true
}
}
if !found {
t.Errorf("deviceproc.go does not import %s", secretPackage)
}
for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go", "objects.go", "capture.go") {
src, _ := os.ReadFile(file)
if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" && file != "objects.go" {
t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file)
}
}
}
@@ -0,0 +1,130 @@
//go:build integration
package main
// The device process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's
// sender_integration_test.go, ADR 0436 §7): a JetStream server of the test's own, the work-queue stream
// device-asks and its consumer, the state bucket device-answers, and a fake printer. An ask published on the
// stream is answered in the state under its id and acknowledged. Compiled by the merge gate's
// `go vet -tags integration`; never run on this machine.
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"brother/cmd/brother/internal/secret"
)
// printerAskedSubject is the wire subject the controller composes for the stream's one event:
// "mesh.stream." + module + ".event." + event (mesh-controller internal/broker/modstreams.go).
const printerAskedSubject = "mesh.stream.brother.event." + EventAsked
func newTestBus(t *testing.T) string {
t.Helper()
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
NoLog: true, NoSigs: true}
s, err := server.NewServer(opts)
if err != nil {
t.Fatalf("a bus for this test could not be made: %v", err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
s.Shutdown()
t.Fatal("a bus for this test did not come up within 30s")
}
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
return s.ClientURL()
}
// 1453: an ask on device-asks is answered under its id in device-answers by the device process, over the
// printer's pinned HTTPS and the login, and acknowledged; stopping it leaves nothing in hand.
func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
url := newTestBus(t)
ctx := context.Background()
nc, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: AsksStream, Subjects: []string{printerAskedSubject},
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
t.Fatal(err)
}
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
t.Fatal(err)
}
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: account.Module + "_" + AnswersState, TTL: 10 * time.Minute})
if err != nil {
t.Fatal(err)
}
f := newFakePrinter(t, "B1891", "pw")
dir := t.TempDir()
settings := filepath.Join(dir, "settings.json")
os.WriteFile(settings, []byte(`{"printer":"`+f.host()+`","printer-certificate":"`+f.print()+`"}`), 0o600)
pwFile := filepath.Join(dir, "admin-password.secret")
os.WriteFile(pwFile, []byte("pw\n"), 0o600)
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(settings) },
Password: func() (string, error) { return secret.Read(pwFile) },
Now: time.Now,
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- runDevice(runCtx, account, w) }()
body, _ := json.Marshal(Ask{ID: "ask-1", Op: OpSettings, Page: "/general/other.html", At: time.Now()})
if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil {
t.Fatal(err)
}
var answer string
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := kv.Get(ctx, "ask-1"); err == nil {
answer = string(e.Value())
break
}
}
if !strings.Contains(answer, "Synthetic settings") {
t.Fatalf("the answer under ask-1 is %q", answer)
}
if strings.Contains(answer, "shouldnotshow") {
t.Errorf("a password field's value is in the answer")
}
time.Sleep(500 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("runDevice returned nil, want ctx.Err() once its context was cancelled")
}
case <-time.After(10 * time.Second):
t.Fatal("runDevice did not stop once its context was cancelled")
}
cons, err := js.Consumer(ctx, AsksStream, account.QueueConsumerName())
if err != nil {
t.Fatal(err)
}
info, err := cons.Info(ctx)
if err != nil {
t.Fatal(err)
}
if info.NumPending != 0 || info.NumAckPending != 0 {
t.Errorf("the ask was not acknowledged: %d pending, %d awaiting an acknowledgement", info.NumPending, info.NumAckPending)
}
}
+437
View File
@@ -0,0 +1,437 @@
package main
// mesh brother device (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the device process, a process
// with a bus account of its own (brother-device), never a seat. It alone of the module's parts reads the admin
// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream device-asks
// and read its answer in the state device-answers. The admin password is never in an answer, an event or a log.
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/process"
"brother/cmd/brother/internal/secret"
)
// Answers is the state the worker writes an answer in, once.
type Answers interface {
Create(key string, value any) (uint64, error)
}
// action is what is done to the queued message.
type action int
const (
actionAck action = iota
actionRetry
actionTerm
// actionSettled says the message was acknowledged already, before the work began: what is not safe to do twice
// (a scan, a print) is acknowledged first, so a slow one is not delivered again after the bus's wait.
actionSettled
)
// pendingUpload is the parts of an upload that have come.
type pendingUpload struct {
parts map[int][]byte
n int
sha string
at time.Time
}
// pendingFor is how long an incomplete upload's parts are kept.
const pendingFor = 15 * time.Minute
// deviceWorker answers the asks: it alone talks to the device (the scanner, the printer's pages and IPP) and to
// the bucket. Settings and the password are read at each ask, so a changed config value or a password given later
// is the next ask's.
type deviceWorker struct {
Settings func() (Settings, error)
Password func() (string, error)
Objects func() (Objects, error)
// Emit says scan.added on the bus under a message id.
Emit func(body map[string]any, id string) error
Answers Answers
Node string
Now func() time.Time
Logf func(format string, a ...any)
mu sync.Mutex
pending map[string]*pendingUpload
}
func (w *deviceWorker) logf(format string, a ...any) {
if w.Logf != nil {
w.Logf(format, a...)
}
}
// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait for
// is acknowledged and the device not touched; every other outcome, a failure included, is written as the answer
// under the ask's id and the message acknowledged; only an answer that could not be written is redelivered (the
// bus's limit ends that). early acknowledges the message; it is called before a scan or a print, which are not
// done twice.
func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration) {
var ask Ask
if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" {
w.logf("[brother] device: a queued ask could not be read: %v", err)
return actionTerm, 0
}
if age := w.Now().Sub(ask.At); age > StaleAfter {
w.logf("[brother] device: ask %s is %s old; nobody waits for it, the device is not touched", ask.ID, age.Round(time.Second))
return actionAck, 0
}
settled := false
if (ask.Op == OpScan || ask.Op == OpPrint) && early != nil {
early()
settled = true
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var answer map[string]any
if got, err := w.run(ctx, ask); err != nil {
answer = map[string]any{"error": err.Error()}
} else {
answer = got
}
if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) {
w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err)
if settled {
return actionSettled, 0
}
return actionRetry, 5 * time.Second
}
if settled {
return actionSettled, 0
}
return actionAck, 0
}
// run is the operation an ask names.
func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) {
switch ask.Op {
case OpSettings:
return w.read(ctx, ask.Page)
case OpScan:
return w.scan(ctx)
case OpDocument:
return w.document(ctx, ask)
case OpUpload:
return w.upload(ctx, ask)
case OpPrint:
return w.print(ctx, ask)
}
return nil, fmt.Errorf("the ask %s names an operation, %q, the device process does not do", ask.ID, ask.Op)
}
// scan scans the glass, crops, builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with
// the object's key (ADR 0468 clause 2), in that order: nothing is said of a scan that is not kept, and nothing is
// kept nor said when the scanner gives no page (1440/AC6). The answer is the event's body with key, size and the
// number of parts.
func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) {
set, err := w.Settings()
if err != nil {
return nil, err
}
if set.Scanner == "" {
return nil, unsetError("scanner")
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
svc := &Service{Settings: set, Node: w.Node, Now: w.Now}
c, err := svc.Capture(ctx)
if err != nil {
return nil, err
}
body := c.Body(w.Node, set.ColorMode)
key := body["object"].(string)
if err := Store(ctx, objects, key, "application/pdf", c.PDF); err != nil {
return nil, err
}
if err := w.Emit(body, c.SHA256); err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but %s could not be said on the bus: %w", key, EventAdded, err)
}
answer := map[string]any{"key": key, "size": len(c.PDF), "parts": partCount(len(c.PDF))}
for k, v := range body {
answer[k] = v
}
return answer, nil
}
// document is one part of a scan kept in the bucket.
func (w *deviceWorker) document(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkDocument(ask); err != nil {
return nil, err
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
data, err := objects.Get(ctx, ask.Object)
if err != nil {
return nil, err
}
part, n, err := cutPart(data, ask.Part)
if err != nil {
return nil, err
}
return map[string]any{"part": ask.Part, "parts": n, "size": len(data), "sha256": sha256Hex(data),
"data": base64.StdEncoding.EncodeToString(part)}, nil
}
// upload keeps a part, and once all have come joins them, checks the SHA-256 and the format and stores the document
// as uploads/<upload>.<ext>. A wrong SHA-256 or a document that is neither PDF nor JPEG stores nothing, and the
// parts are dropped; a part out of range is refused.
func (w *deviceWorker) upload(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkUpload(ask); err != nil {
return nil, err
}
raw, _ := base64.StdEncoding.DecodeString(ask.Data)
w.mu.Lock()
if w.pending == nil {
w.pending = map[string]*pendingUpload{}
}
for id, p := range w.pending {
if w.Now().Sub(p.at) > pendingFor {
delete(w.pending, id)
}
}
p := w.pending[ask.Upload]
if p == nil {
p = &pendingUpload{parts: map[int][]byte{}, n: ask.Parts, sha: ask.SHA256}
w.pending[ask.Upload] = p
}
if p.n != ask.Parts || p.sha != ask.SHA256 {
w.mu.Unlock()
return nil, fmt.Errorf("upload %s was begun with %d parts and SHA-256 %s: its parts all say the same", ask.Upload, p.n, p.sha)
}
p.parts[ask.Part] = raw
p.at = w.Now()
if len(p.parts) < p.n {
got := len(p.parts)
w.mu.Unlock()
return map[string]any{"upload": ask.Upload, "complete": false, "received": got, "parts": ask.Parts}, nil
}
delete(w.pending, ask.Upload)
w.mu.Unlock()
ordered := make([][]byte, p.n)
for i := range ordered {
ordered[i] = p.parts[i+1]
}
doc, err := joinParts(ordered, p.sha)
if err != nil {
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
}
format, ext, err := SniffFormat(doc)
if err != nil {
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
key := "uploads/" + ask.Upload + "." + ext
if err := objects.Put(ctx, key, format, doc); err != nil {
return nil, err
}
return map[string]any{"upload": ask.Upload, "complete": true, "object": key, "size": len(doc), "sha256": p.sha}, nil
}
// print fetches a scan or a completed upload from the bucket and sends it to the printer as an IPP Print-Job in
// its own format, and answers the job's id and state. The printer's host is its IPP address; nothing is fetched
// or sent before the ask is checked.
func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkPrint(&ask); err != nil {
return nil, err
}
set, err := w.Settings()
if err != nil {
return nil, err
}
if set.Printer == "" {
return nil, unsetError("printer")
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
key := ask.Object
if key == "" {
for _, ext := range []string{"pdf", "jpg"} {
k := "uploads/" + ask.Upload + "." + ext
if has, err := objects.Has(ctx, k); err != nil {
return nil, err
} else if has {
key = k
break
}
}
if key == "" {
return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload)
}
}
doc, err := objects.Get(ctx, key)
if err != nil {
return nil, err
}
format, _, err := SniffFormat(doc)
if err != nil {
return nil, err
}
res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc)
if err != nil {
return nil, err
}
return map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode,
"job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}, nil
}
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
// the only things it sends, and a password field's value is never in the answer. The printer is not contacted
// before the settings and the password are known to be given (1453/AC4).
func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
if !validPage(page) {
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
}
set, err := w.Settings()
if err != nil {
return nil, err
}
if err := set.MissingPrinter(); err != nil {
return nil, err
}
pw, err := w.Password()
if err != nil {
return nil, err
}
a, err := NewAdmin(set.Printer, set.PrinterCertificate, pw)
if err != nil {
return nil, err
}
if err := a.Login(ctx); err != nil {
return nil, err
}
// Log out with a context of its own: the ask's may be what ended this.
lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body, at, err := a.Get(ctx, page)
if err != nil {
if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil {
a.Logout(lctx, home, hat)
}
return nil, fmt.Errorf("page %s cannot be read: %w", page, err)
}
logout := a.Logout(lctx, body, at)
p := ParsePage(body)
return map[string]any{
"page": page,
"title": p.Title,
"links": nonNil(p.Links),
"forms": nonNilForms(p.Forms),
"logout": logout,
}, nil
}
func nonNil(l []Link) []Link {
if l == nil {
return []Link{}
}
return l
}
func nonNilForms(f []Form) []Form {
if f == nil {
return []Form{}
}
return f
}
// device is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped.
// It refuses to start naming what is missing when the account cannot be read, and never logs a password.
func device() int {
settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER")
store := os.Getenv("BROTHER_STORE")
if brokerFile == "" {
fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file")
return 2
}
account, err := process.ReadAccount(brokerFile)
if err != nil {
fmt.Fprintf(os.Stderr, "[brother] device: %v\n", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(settings) },
Password: func() (string, error) { return secret.Read(passwordFile) },
Objects: func() (Objects, error) {
facts, err := ReadStoreFacts(store)
if err != nil {
return nil, err
}
return NewS3(facts), nil
},
Now: time.Now,
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
}
fmt.Fprintf(os.Stderr, "[brother] device is consuming %s as %s\n", AsksStream, account.User)
err = runDevice(ctx, account, w)
if err != nil && ctx.Err() == nil {
fmt.Fprintf(os.Stderr, "[brother] device stopped: %v\n", err)
return 1
}
return 0
}
// runDevice connects as the device process's own account and consumes device-asks until ctx ends, answering each
// ask in the state device-answers on that connection and then settling the message as Handle says. Apart from
// device so a test can drive it over a bus of its own.
func runDevice(ctx context.Context, account process.Account, w *deviceWorker) error {
conn, err := process.Connect(account)
if err != nil {
return fmt.Errorf("the bus cannot be reached: %w", err)
}
defer conn.Close()
w.Answers = conn.State(AnswersState)
if w.Node == "" {
w.Node = account.Node
}
w.Emit = func(body map[string]any, id string) error {
return conn.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id})
}
return conn.Consume(ctx, AsksStream, func(msg *process.Message) {
var settleErr error
act, delay := w.Handle(msg.Body, func() { settleErr = msg.Ack() })
if settleErr != nil {
w.logf("[brother] device: acknowledging %s early: %v", msg.Subject, settleErr)
}
var aerr error
switch act {
case actionAck:
aerr = msg.Ack()
case actionRetry:
aerr = msg.NakWithDelay(delay)
case actionTerm:
aerr = msg.Term()
}
if aerr != nil {
w.logf("[brother] device: settling %s: %v", msg.Subject, aerr)
}
})
}
@@ -0,0 +1,461 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// pdfOf is a document that is a PDF to the sniff and size bytes long, its bytes varying so that a part out of
// place changes the join.
func pdfOf(size int) []byte {
b := make([]byte, size)
copy(b, "%PDF-1.4\n")
for i := 9; i < size; i++ {
b[i] = byte(i*7 + i/251)
}
return b
}
// 1453/AC10: a document is cut into parts of at most PartSize, under the limit, and the parts joined in
// order are the document, with the SHA-256 checked; a wrong SHA-256 or a part missing is refused; a part out of
// range is refused naming the range.
func TestADocumentIsCutIntoPartsAndJoinedWithItsSha256Checked(t *testing.T) {
doc := pdfOf(2*PartSize + 1234)
n := partCount(len(doc))
if n != 3 {
t.Fatalf("%d parts, want 3", n)
}
var parts [][]byte
for i := 1; i <= n; i++ {
p, total, err := cutPart(doc, i)
if err != nil || total != n {
t.Fatalf("part %d: %v (of %d)", i, err, total)
}
if len(p) > MaxPart {
t.Errorf("part %d is %d bytes, over %d", i, len(p), MaxPart)
}
parts = append(parts, p)
}
got, err := joinParts(parts, sha256Hex(doc))
if err != nil || !bytes.Equal(got, doc) {
t.Fatalf("the join is not the document: %v", err)
}
if _, err := joinParts(parts, sha256Hex([]byte("other"))); err == nil || !strings.Contains(err.Error(), sha256Hex(doc)) {
t.Errorf("a wrong SHA-256 was accepted or the error does not name the joined one: %v", err)
}
if _, err := joinParts(parts[:2], sha256Hex(doc)); err == nil {
t.Errorf("a join with a part missing passed its SHA-256")
}
for _, bad := range []int{0, -1, n + 1} {
if _, _, err := cutPart(doc, bad); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part %d: error = %v", bad, err)
}
}
if partCount(0) != 1 {
t.Errorf("an empty document has one part")
}
}
// 1453/AC10: scanner.document gives each part of a kept scan with the part count, the size and the SHA-256, each
// answer within the bus's 1 MiB; the parts joined are the scan. A part out of range, or a key that is no scan's, is
// refused, the first before anything is queued.
func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(PartSize + 5000)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
var joined []byte
for i := 1; i <= 2; i++ {
got, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(i)})
if err != nil {
t.Fatalf("part %d: %v", i, err)
}
m := got.(map[string]any)
if m["parts"] != float64(2) || m["size"] != float64(len(doc)) || m["sha256"] != sha256Hex(doc) || m["part"] != float64(i) {
t.Errorf("part %d answer = %v", i, m)
}
raw, err := base64.StdEncoding.DecodeString(m["data"].(string))
if err != nil {
t.Fatal(err)
}
joined = append(joined, raw...)
}
if !bytes.Equal(joined, doc) {
t.Errorf("the parts fetched are not the scan")
}
before := r.bus.asks()
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(0)}); err == nil {
t.Errorf("part 0 was asked for")
}
if r.bus.asks() != before {
t.Errorf("an ask was queued for a part the tool could refuse")
}
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(3)}); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part 3: error = %v", err)
}
for _, k := range []string{"../etc/passwd", "uploads/x.pdf", strings.ToUpper(key)} {
if _, err := r.call(t, "scanner.document", map[string]any{"object": k, "part": float64(1)}); err == nil || !strings.Contains(err.Error(), "not a scan's key") {
t.Errorf("object %q: error = %v", k, err)
}
}
if _, err := r.call(t, "scanner.document", map[string]any{"object": sha256Hex([]byte("none")) + ".pdf", "part": float64(1)}); err == nil {
t.Errorf("a scan that is not kept was given")
}
}
// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed.
func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: false})
// Everything a scan is made of but its size is the real one; the stored object is what the parts carry, so a
// bigger object stands for a bigger scan: replace what the device keeps with a three-part document.
big := pdfOf(2*PartSize + 99)
bigKey := sha256Hex(big) + ".pdf"
r.objects.Put(t.Context(), bigKey, "application/pdf", big)
got := make([][]byte, 0, 3)
for i := 1; i <= 3; i++ {
p, err := r.worker.document(t.Context(), Ask{Op: OpDocument, Object: bigKey, Part: i})
if err != nil {
t.Fatal(err)
}
raw, _ := base64.StdEncoding.DecodeString(p["data"].(string))
got = append(got, raw)
}
if joined, err := joinParts(got, sha256Hex(big)); err != nil || !bytes.Equal(joined, big) {
t.Fatalf("three parts do not join: %v", err)
}
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if body["path"] == nil {
t.Errorf("no path: %v", body)
}
}
func upload(t *testing.T, r *printerRig, id string, doc []byte, sha string, part, parts int, piece []byte) (any, error) {
t.Helper()
return r.call(t, "printer.upload", map[string]any{"upload": id, "part": float64(part), "parts": float64(parts), "sha256": sha,
"data": base64.StdEncoding.EncodeToString(piece)})
}
// 1453/AC11: an upload in parts, in any order, is stored as uploads/<id>.pdf once all have come and the joined
// SHA-256 matches; until then it says how many came and stores nothing.
func TestAnUploadIsStoredOnceAllItsPartsCameAndTheSha256Matches(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(PartSize + 700)
sum := sha256Hex(doc)
p1, _, _ := cutPart(doc, 1)
p2, _, _ := cutPart(doc, 2)
got, err := upload(t, r, "up-1", doc, sum, 2, 2, p2)
if err != nil {
t.Fatal(err)
}
if m := got.(map[string]any); m["complete"] != false || m["received"] != float64(1) {
t.Errorf("after one part: %v", m)
}
if len(r.objects.keys()) != 0 {
t.Errorf("a document was stored before its parts all came: %v", r.objects.keys())
}
got, err = upload(t, r, "up-1", doc, sum, 1, 2, p1)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["complete"] != true || m["object"] != "uploads/up-1.pdf" || m["sha256"] != sum || m["size"] != float64(len(doc)) {
t.Errorf("after the last part: %v", m)
}
if kept, err := r.objects.Get(t.Context(), "uploads/up-1.pdf"); err != nil || !bytes.Equal(kept, doc) {
t.Errorf("the stored document is not the upload (%v)", err)
}
// A JPEG is stored with its own extension.
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{7}, 100)...)
if _, err := upload(t, r, "pic", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
t.Fatal(err)
}
if _, err := r.objects.Get(t.Context(), "uploads/pic.jpg"); err != nil {
t.Errorf("the JPEG is not stored as uploads/pic.jpg: %v", err)
}
}
// 1453/AC11: a wrong SHA-256 on the joined document stores nothing and is said; a document that is neither PDF nor
// JPEG stores nothing; a part out of range, a part larger than the limit, a part that is not base64 and parts that
// disagree are refused; the upload can then be sent again whole.
func TestAnUploadWithAWrongSha256OrAnOutOfRangePartStoresNothing(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(300)
wrong := sha256Hex([]byte("not it"))
if _, err := upload(t, r, "bad", doc, wrong, 1, 1, doc); err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), sha256Hex(doc)) {
t.Errorf("a wrong SHA-256: error = %v", err)
}
if len(r.objects.keys()) != 0 || r.objects.puts != 0 {
t.Errorf("a document with a wrong SHA-256 was stored: %v", r.objects.keys())
}
text := []byte("plain text, no PDF")
if _, err := upload(t, r, "txt", text, sha256Hex(text), 1, 1, text); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
t.Errorf("a text upload: error = %v", err)
}
for _, c := range []struct{ part, parts int }{{0, 1}, {2, 1}, {1, 0}, {1, MostParts + 1}} {
if _, err := upload(t, r, "range", doc, sha256Hex(doc), c.part, c.parts, doc); err == nil {
t.Errorf("part %d of %d was taken", c.part, c.parts)
}
}
if _, err := upload(t, r, "range", doc, sha256Hex(doc), 2, 1, doc); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part 2 of 1: error = %v", err)
}
if _, err := r.call(t, "printer.upload", map[string]any{"upload": "x", "part": float64(1), "parts": float64(1), "sha256": sha256Hex(doc), "data": "!!"}); err == nil {
t.Errorf("a part that is not base64 was taken")
}
if _, err := upload(t, r, "big", doc, sha256Hex(doc), 1, 1, make([]byte, MaxPart+1)); err == nil || !strings.Contains(err.Error(), "at most") {
t.Errorf("a part over the limit: error = %v", err)
}
if r.bus.asks() != 2 {
// Only the two uploads that were complete in form reached the device process; every refusal above them
// was the tool's.
t.Errorf("%d asks queued, want the 2 whose form was right", r.bus.asks())
}
// Parts that disagree about the whole.
p := pdfOf(PartSize + 10)
p1, _, _ := cutPart(p, 1)
if _, err := upload(t, r, "mix", p, sha256Hex(p), 1, 2, p1); err != nil {
t.Fatal(err)
}
if _, err := upload(t, r, "mix", p, sha256Hex(p), 2, 3, p1); err == nil || !strings.Contains(err.Error(), "all say the same") {
t.Errorf("parts that disagree: error = %v", err)
}
if len(r.objects.keys()) != 0 {
t.Errorf("objects stored: %v", r.objects.keys())
}
}
// 1453/AC11: an incomplete upload's parts are dropped after fifteen minutes.
func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(PartSize + 10)
p1, _, _ := cutPart(doc, 1)
p2, _, _ := cutPart(doc, 2)
now := fixedNow
r.worker.Now = func() time.Time { return now }
if _, err := upload(t, r, "late", doc, sha256Hex(doc), 1, 2, p1); err != nil {
t.Fatal(err)
}
now = now.Add(pendingFor + time.Minute)
// Asked of the worker directly: the tool's own clock is the fixed one, and an ask this old would be stale.
got, err := r.worker.upload(t.Context(), Ask{Op: OpUpload, Upload: "late", Part: 2, Parts: 2, SHA256: sha256Hex(doc),
Data: base64.StdEncoding.EncodeToString(p2)})
if err != nil {
t.Fatal(err)
}
if got["complete"] != false {
t.Errorf("the dropped part was still there: %v", got)
}
}
// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state.
type ippFake struct {
url string
body []byte
status int
}
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
t.Helper()
f := &ippFake{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.body, _ = io.ReadAll(r.Body)
ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation}
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
ans = append(ans, ippJob)
ans = appendAttr(ans, ippInteger, "job-id", string(binary.BigEndian.AppendUint32(nil, uint32(jobID))))
ans = appendAttr(ans, ippEnum, "job-state", string(binary.BigEndian.AppendUint32(nil, uint32(jobState))))
ans = appendAttr(ans, ippKeyword, "job-state-reasons", "none")
ans = append(ans, ippEnd)
w.Header().Set("Content-Type", "application/ipp")
w.Write(ans)
}))
t.Cleanup(srv.Close)
f.url = srv.URL
old := ippURL
ippURL = func(string) string { return srv.URL }
t.Cleanup(func() { ippURL = old })
return f
}
// request splits what the fake was sent into its attributes and its document.
func (f *ippFake) request(t *testing.T) (IPPAnswer, []byte) {
t.Helper()
a, off, err := decodeIPP(f.body)
if err != nil {
t.Fatal(err)
}
return a, f.body[off:]
}
// 1453/AC12: printer.print sends a kept scan to the printer as an IPP Print-Job (operation 0x0002): the document
// bytes after the attributes, document-format by sniffing, copies, sides and media as asked, to the printer's own
// IPP address; the answer is the job's id and state.
func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
fake := newIPPFake(t, 77, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(5000)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
got, err := r.call(t, "printer.print", map[string]any{"object": key, "copies": float64(2), "sides": "two-sided-long-edge", "media": "na_letter_8.5x11in"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["job-id"] != float64(77) || m["job-state"] != "processing" || m["format"] != "application/pdf" {
t.Errorf("answer = %v", m)
}
if binary.BigEndian.Uint16(fake.body[2:4]) != 0x0002 {
t.Errorf("operation = 0x%04x, want Print-Job 0x0002", binary.BigEndian.Uint16(fake.body[2:4]))
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, doc) {
t.Errorf("the document sent is not the kept one (%d bytes, want %d)", len(sent), len(doc))
}
text := func(n string) string { return strings.Join(attrs.texts(n), ",") }
if text("document-format") != "application/pdf" || text("sides") != "two-sided-long-edge" || text("media") != "na_letter_8.5x11in" ||
text("printer-uri") != "ipp://192.0.2.10/ipp/port1" {
t.Errorf("attributes = %v", attrs.Attributes)
}
if c := attrs.Attributes["copies"]; len(c) != 1 || c[0].Integer != 2 {
t.Errorf("copies = %v", c)
}
}
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id.
func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
fake := newIPPFake(t, 8, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...)
if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
t.Fatal(err)
}
got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"})
if err != nil {
t.Fatal(err)
}
if got.(map[string]any)["job-state"] != "pending" {
t.Errorf("answer = %v", got)
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, jpg) || strings.Join(attrs.texts("document-format"), "") != "image/jpeg" ||
strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 {
t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent))
}
if _, err := r.call(t, "printer.print", map[string]any{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") {
t.Errorf("an upload never made: error = %v", err)
}
}
// 1453/AC12: a print that is not well formed is refused before anything is queued; the printer's refusal is an
// error naming its IPP status; a printer not set is said by name.
func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
fake := newIPPFake(t, 1, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
key := sha256Hex([]byte("x")) + ".pdf"
for name, args := range map[string]map[string]any{
"neither": {},
"both": {"object": key, "upload": "u"},
"copies 0": {"object": key, "copies": float64(-1)},
"copies 100": {"object": key, "copies": float64(100)},
"sides": {"object": key, "sides": "sideways"},
"media": {"object": key, "media": "A4 paper"},
"not a key": {"object": "../x.pdf"},
"copies float": {"object": key, "copies": 1.5},
} {
if _, err := r.call(t, "printer.print", args); err == nil {
t.Errorf("%s: accepted", name)
}
}
if r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d asks queued, the printer was sent %d bytes", r.bus.asks(), len(fake.body))
}
doc := pdfOf(100)
dk := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), dk, "application/pdf", doc)
fake.status = 0x0400
if _, err := r.call(t, "printer.print", map[string]any{"object": dk}); err == nil || !strings.Contains(err.Error(), "0x0400") {
t.Errorf("a refused job: error = %v", err)
}
// A kept object that is not a document the printer takes is not sent.
fake.status, fake.body = 0, nil
r.objects.Put(t.Context(), key, "text/plain", []byte("hello"))
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") || fake.body != nil {
t.Errorf("a text object: error = %v, sent %d bytes", err, len(fake.body))
}
un := newRig(t, `{}`, "pw")
if _, err := un.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "printer is not set") || un.bus.asks() != 0 {
t.Errorf("no printer: error = %v", err)
}
}
// The Print-Job request is the one RFC 8011 gives: header, operation attributes in order, the job's own, the
// end-of-attributes tag, then the document.
func TestThePrintJobRequestIsWellFormed(t *testing.T) {
doc := []byte("%PDF-1.4 hello")
b := EncodePrintJob("ipp://192.0.2.10/ipp/port1", PrintOptions{Format: "application/pdf", Copies: 3, Sides: "one-sided", Media: DefaultMedia, Name: "n"}, doc)
if !bytes.Equal(b[:8], []byte{2, 0, 0, 2, 0, 0, 0, 1}) || b[8] != ippOperation {
t.Errorf("header = %x", b[:9])
}
a, off, err := decodeIPP(b)
if err != nil || !bytes.Equal(b[off:], doc) {
t.Fatalf("decode: %v, document %q", err, b[off:])
}
if first := string(b[9:]); !strings.HasPrefix(first[3:], "attributes-charset") {
t.Errorf("the first attribute is not attributes-charset")
}
if len(a.Attributes["copies"]) != 1 || a.Attributes["copies"][0].Integer != 3 {
t.Errorf("copies = %v", a.Attributes["copies"])
}
}
// 1453: a scan and a print are acknowledged before the work (a slow one is not delivered again and done twice);
// the other operations are acknowledged after it.
func TestAScanAndAPrintAreAcknowledgedBeforeTheWork(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: true})
body, _ := json.Marshal(Ask{ID: "s1", Op: OpScan, At: fixedNow})
calls := 0
var postsAtAck int
act, _ := r.worker.Handle(body, func() { calls++; postsAtAck = f.posts })
if calls != 1 || postsAtAck != 0 || act != actionSettled || f.posts != 1 {
t.Errorf("early called %d times with %d scans begun; action %v; %d scans in all", calls, postsAtAck, act, f.posts)
}
body, _ = json.Marshal(Ask{ID: "s2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
calls = 0
if act, _ := r.worker.Handle(body, func() { calls++ }); calls != 0 || act != actionAck {
t.Errorf("settings: early called %d times, action %v", calls, act)
}
}
// 1453/AC11: a part of 700 KiB is taken and one of a byte more is refused, by the module's own message naming
// 700 KiB, before it is queued; the limit and the size the module cuts are one.
func TestAPartOverSevenHundredKiBIsRefusedByTheModulesOwnMessage(t *testing.T) {
if MaxPart != PartSize || PartSize != 700<<10 {
t.Fatalf("MaxPart %d, PartSize %d: both are 700 KiB", MaxPart, PartSize)
}
r := newRig(t, `{}`, "pw")
big := make([]byte, 700<<10+1)
_, err := upload(t, r, "over", big, sha256Hex(big), 1, 1, big)
if err == nil || !strings.Contains(err.Error(), "700 KiB") {
t.Fatalf("a part of 700 KiB + 1 byte: error = %v, want the module's own message naming 700 KiB", err)
}
if r.bus.asks() != 0 {
t.Errorf("an oversized part was queued")
}
ok := pdfOf(700 << 10)
if _, err := upload(t, r, "exact", ok, sha256Hex(ok), 1, 1, ok); err != nil {
t.Errorf("a part of exactly 700 KiB: %v", err)
}
}
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"html"
"regexp"
"strings"
"unicode/utf8"
)
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
// reads is taken out of them: the title, the links, and the forms with their fields.
// Link is an anchor of a page.
type Link struct {
Href string `json:"href"`
Text string `json:"text"`
}
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
type Field struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
Label string `json:"label"`
Options []string `json:"options,omitempty"`
id string
}
// Form is a form of a page with its named fields.
type Form struct {
Action string `json:"action"`
Method string `json:"method"`
Fields []Field `json:"fields"`
}
// Page is what a page of the admin site holds.
type Page struct {
Title string `json:"title"`
Links []Link `json:"links"`
Forms []Form `json:"forms"`
}
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
// attrs reads a tag's attributes, names in lower case and values unescaped.
func attrs(s string) map[string]string {
out := map[string]string{}
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
v := m[2] + m[3] + m[4]
out[strings.ToLower(m[1])] = html.UnescapeString(v)
}
return out
}
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
// the printer's pages say iso-8859-1.
func latin1(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
func tagEnd(s string) int {
var q byte
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case q != 0:
if c == q {
q = 0
}
case c == '"' || c == '\'':
q = c
case c == '>':
return i
}
}
return -1
}
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
func ParsePage(body []byte) Page {
s := latin1(body)
var p Page
var (
inTitle bool
title strings.Builder
link *Link
formOpen bool
form Form
labelFor string
inLabel bool
label strings.Builder
labelIDs = map[string]string{}
wrapped []int // fields inside a label that names none, by index in form.Fields
selIdx = -1
inOption bool
optValue string
optHasV bool
optSel bool
optText strings.Builder
inTextA bool
textA strings.Builder
textAIdx = -1
)
finishOption := func() {
if !inOption || selIdx < 0 {
inOption = false
return
}
v := optValue
if !optHasV {
v = squash(optText.String())
}
f := &form.Fields[selIdx]
f.Options = append(f.Options, v)
if optSel {
f.Value = v
}
inOption = false
}
closeForm := func() {
finishOption()
selIdx = -1
if formOpen {
p.Forms = append(p.Forms, form)
}
formOpen = false
form = Form{}
}
addField := func(f Field) {
form.Fields = append(form.Fields, f)
if inLabel && labelFor == "" {
wrapped = append(wrapped, len(form.Fields)-1)
}
}
text := func(t string) {
switch {
case inTitle:
title.WriteString(t)
case inOption:
optText.WriteString(t)
case inTextA:
textA.WriteString(t)
}
if link != nil {
link.Text += t
}
if inLabel {
label.WriteString(t)
}
}
i := 0
for i < len(s) {
lt := strings.IndexByte(s[i:], '<')
if lt < 0 {
text(s[i:])
break
}
text(s[i : i+lt])
i += lt
switch {
case strings.HasPrefix(s[i:], "<!--"):
end := strings.Index(s[i:], "-->")
if end < 0 {
i = len(s)
} else {
i += end + 3
}
continue
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
end := strings.IndexByte(s[i:], '>')
if end < 0 {
i = len(s)
} else {
i += end + 1
}
continue
}
end := tagEnd(s[i:])
if end < 0 {
break
}
raw := s[i+1 : i+end]
i += end + 1
closing := strings.HasPrefix(raw, "/")
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
name := raw
rest := ""
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
name, rest = raw[:sp], raw[sp:]
}
name = strings.ToLower(name)
if !closing && (name == "script" || name == "style") {
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
i += e
} else {
i = len(s)
}
continue
}
a := map[string]string{}
if !closing {
a = attrs(rest)
}
switch name {
case "title":
inTitle = !closing
case "a":
if closing {
if link != nil {
link.Text = squash(link.Text)
p.Links = append(p.Links, *link)
}
link = nil
} else if href, ok := a["href"]; ok {
link = &Link{Href: href}
}
case "form":
closeForm()
if !closing {
formOpen = true
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
if form.Method == "" {
form.Method = "get"
}
}
case "label":
if closing {
t := squash(label.String())
if labelFor != "" {
labelIDs[labelFor] = t
}
for _, k := range wrapped {
form.Fields[k].Label = t
}
inLabel, labelFor, wrapped = false, "", nil
label.Reset()
} else {
inLabel, labelFor = true, a["for"]
label.Reset()
}
case "input":
if closing || a["name"] == "" {
break
}
typ := strings.ToLower(a["type"])
if typ == "" {
typ = "text"
}
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
break
}
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
if typ == "password" {
f.Value = ""
}
addField(f)
case "select":
finishOption()
selIdx = -1
if !closing && a["name"] != "" {
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
selIdx = len(form.Fields) - 1
}
case "option":
finishOption()
if !closing && selIdx >= 0 {
inOption = true
optValue, optHasV = a["value"], false
if _, ok := a["value"]; ok {
optHasV = true
}
_, optSel = a["selected"]
optText.Reset()
}
case "textarea":
if closing {
if inTextA && textAIdx >= 0 {
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
}
inTextA = false
} else if a["name"] != "" {
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
inTextA, textAIdx = true, len(form.Fields)-1
textA.Reset()
}
}
}
closeForm()
p.Title = squash(title.String())
for fi := range p.Forms {
for k := range p.Forms[fi].Fields {
f := &p.Forms[fi].Fields[k]
if f.Label == "" && f.id != "" {
f.Label = labelIDs[f.id]
}
}
}
return p
}
@@ -0,0 +1,30 @@
// Package secret reads the module's own secret admin-password (novox/hq ADR 0283, ADR 0467 clause 4). It is
// imported only by the admin process (adminproc.go), which runs as its own account, brother-admin, and alone
// can open the file: never by the tools, which the operator account's tool runner serves and which must never
// open a password file (novox/hq ADR 0436 §12). admin_boundary_test.go checks that.
package secret
import (
"errors"
"fmt"
"os"
"strings"
)
// Read is the password in the file at path. A password not given yet (no file, or an empty one) is an error
// that names the secret and says the operator gives it at the desk, and that nothing was contacted.
func Read(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code")
}
raw, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("the secret admin-password cannot be read: %w", err)
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " +
"who gives it at their desk (ADR 0283)")
}
return pw, nil
}
@@ -0,0 +1,27 @@
package secret
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The password is the file's content without its trailing newline; no file or an empty one is "not given",
// said by the secret's name; the password itself is never in an error.
func TestReadGivesThePasswordOrSaysItIsNotGiven(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "admin-password.secret")
os.WriteFile(p, []byte("p4ss\n"), 0o600)
if pw, err := Read(p); err != nil || pw != "p4ss" {
t.Errorf("Read = %q, %v", pw, err)
}
for _, path := range []string{filepath.Join(dir, "none"), func() string { e := filepath.Join(dir, "e"); os.WriteFile(e, []byte("\r\n"), 0o600); return e }()} {
if _, err := Read(path); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
t.Errorf("Read(%s) error = %v", path, err)
}
}
if _, err := Read(""); err == nil || !strings.Contains(err.Error(), "BROTHER_ADMIN_PASSWORD") {
t.Errorf("Read(\"\") error = %v", err)
}
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// IPP (RFC 8010, RFC 8011) by hand: Get-Printer-Attributes and Print-Job, neither of which needs a login.
const (
ippGetPrinterAttributes = 0x000B
ippPrintJob = 0x0002
ippOperation = 0x01
ippJob = 0x02
ippEnd = 0x03
ippInteger = 0x21
ippEnum = 0x23
ippNameNoLang = 0x42
ippKeyword = 0x44
ippURItag = 0x45
ippMimeType = 0x49
ippCharset = 0x47
ippLanguage = 0x48
)
// statusAttributes are the attributes printer.status asks for.
var statusAttributes = []string{
"printer-state", "printer-state-reasons", "marker-names", "marker-levels", "marker-colors", "printer-make-and-model",
}
// ippURL is where a printer's IPP service answers: the port IPP is registered on, and the path the printer
// serves (CUPS shows ipp://<printer>/ipp/port1). A variable so a test can put a server of its own in its place.
var ippURL = func(host string) string {
h := host
if hh, _, err := net.SplitHostPort(host); err == nil {
h = hh
}
if strings.Contains(h, ":") {
h = "[" + h + "]"
}
return "http://" + h + ":631/ipp/port1"
}
// ippPrinterURI is the printer-uri operation attribute for a host.
func ippPrinterURI(host string) string {
h := host
if hh, _, err := net.SplitHostPort(host); err == nil {
h = hh
}
return "ipp://" + h + "/ipp/port1"
}
func appendAttr(b []byte, tag byte, name, value string) []byte {
b = append(b, tag)
b = binary.BigEndian.AppendUint16(b, uint16(len(name)))
b = append(b, name...)
b = binary.BigEndian.AppendUint16(b, uint16(len(value)))
return append(b, value...)
}
// EncodeGetPrinterAttributes is the request: IPP 2.0, operation 0x000B, request id 1, the operation attributes
// RFC 8011 requires first (charset, natural language, printer-uri), and the attributes asked for.
func EncodeGetPrinterAttributes(printerURI string, requested []string) []byte {
b := []byte{0x02, 0x00, 0x00, byte(ippGetPrinterAttributes), 0, 0, 0, 1, ippOperation}
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
for i, r := range requested {
name := ""
if i == 0 {
name = "requested-attributes"
}
b = appendAttr(b, ippKeyword, name, r)
}
return append(b, ippEnd)
}
// IPPValue is one value of an attribute: an integer or enum, or text.
type IPPValue struct {
Tag byte
Integer int
Text string
}
// IPPAnswer is a decoded response: its status code and its attributes by name, each with its values in order.
type IPPAnswer struct {
Status int
Attributes map[string][]IPPValue
}
// DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of
// integers, enums and strings, whose additional values have an empty name.
func DecodeIPP(b []byte) (IPPAnswer, error) {
a, _, err := decodeIPP(b)
return a, err
}
// decodeIPP is DecodeIPP and the offset after the end-of-attributes tag, where a request's document begins.
func decodeIPP(b []byte) (IPPAnswer, int, error) {
a := IPPAnswer{Attributes: map[string][]IPPValue{}}
if len(b) < 9 {
return a, 0, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b))
}
a.Status = int(binary.BigEndian.Uint16(b[2:4]))
i := 8
last := ""
for i < len(b) {
tag := b[i]
i++
if tag == ippEnd {
return a, i, nil
}
if tag <= 0x05 { // a group's start
last = ""
continue
}
if i+2 > len(b) {
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute")
}
nl := int(binary.BigEndian.Uint16(b[i:]))
i += 2
if i+nl+2 > len(b) {
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute name")
}
name := string(b[i : i+nl])
i += nl
vl := int(binary.BigEndian.Uint16(b[i:]))
i += 2
if i+vl > len(b) {
return a, 0, fmt.Errorf("the IPP answer ends inside the value of %q", name)
}
raw := b[i : i+vl]
i += vl
if name == "" {
name = last
}
last = name
v := IPPValue{Tag: tag}
if (tag == ippInteger || tag == ippEnum) && vl == 4 {
v.Integer = int(int32(binary.BigEndian.Uint32(raw)))
} else {
v.Text = string(raw)
}
a.Attributes[name] = append(a.Attributes[name], v)
}
return a, 0, fmt.Errorf("the IPP answer has no end-of-attributes tag")
}
func (a IPPAnswer) texts(name string) []string {
var out []string
for _, v := range a.Attributes[name] {
out = append(out, v.Text)
}
return out
}
// printerStates are the values RFC 8011 gives printer-state.
var printerStates = map[int]string{3: "idle", 4: "processing", 5: "stopped"}
// Marker is one consumable of the printer: a toner, a drum, a belt. A level below 0 is the standard's way
// to say it is unknown (-1) or not reported (-2).
type Marker struct {
Name string `json:"name"`
Level int `json:"level"`
Color string `json:"color,omitempty"`
}
// PrinterState is what printer.status reads over IPP.
type PrinterState struct {
State string `json:"state"`
StateCode int `json:"state-code"`
Reasons []string `json:"state-reasons"`
MakeAndModel string `json:"make-and-model"`
Markers []Marker `json:"markers"`
}
// State reads the answer into the printer's state; an answer whose status is not a success is an error.
func (a IPPAnswer) State() (PrinterState, error) {
if a.Status >= 0x0100 {
return PrinterState{}, fmt.Errorf("the printer refused the IPP request with status 0x%04x", a.Status)
}
st := a.Attributes["printer-state"]
if len(st) == 0 {
return PrinterState{}, fmt.Errorf("the IPP answer holds no printer-state")
}
code := st[0].Integer
name, ok := printerStates[code]
if !ok {
name = fmt.Sprintf("unknown (%d)", code)
}
s := PrinterState{State: name, StateCode: code, Reasons: a.texts("printer-state-reasons"), Markers: []Marker{}}
if s.Reasons == nil {
s.Reasons = []string{}
}
if m := a.texts("printer-make-and-model"); len(m) > 0 {
s.MakeAndModel = m[0]
}
names, levels, colors := a.texts("marker-names"), a.Attributes["marker-levels"], a.texts("marker-colors")
for i, n := range names {
m := Marker{Name: n, Level: -1}
if i < len(levels) {
m.Level = levels[i].Integer
}
if i < len(colors) {
m.Color = colors[i]
}
s.Markers = append(s.Markers, m)
}
return s, nil
}
// PrinterStateOf asks the printer for its state over IPP. The printer is asked on its IPP port, not through the
// pinned HTTPS client, and nothing secret is sent.
func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), statusAttributes)))
if err != nil {
return PrinterState{}, err
}
req.Header.Set("Content-Type", "application/ipp")
c := &http.Client{Timeout: 15 * time.Second}
resp, err := c.Do(req)
if err != nil {
return PrinterState{}, fmt.Errorf("the printer's IPP service does not answer: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return PrinterState{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return PrinterState{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
}
ans, err := DecodeIPP(body)
if err != nil {
return PrinterState{}, err
}
return ans.State()
}
// PrintOptions are what a Print-Job asks of the printer.
type PrintOptions struct {
Format string // document-format, application/pdf or image/jpeg
Copies int
Sides string
Media string
Name string // job-name
}
// SniffFormat is the document format of the bytes the printer accepts: PDF or JPEG, by their first bytes, and
// otherwise an error.
func SniffFormat(doc []byte) (format, ext string, err error) {
switch {
case bytes.HasPrefix(doc, []byte("%PDF-")):
return "application/pdf", "pdf", nil
case len(doc) > 3 && doc[0] == 0xFF && doc[1] == 0xD8 && doc[2] == 0xFF:
return "image/jpeg", "jpg", nil
}
return "", "", fmt.Errorf("the document is neither a PDF nor a JPEG: the printer is sent those two only")
}
// EncodePrintJob is the Print-Job request (RFC 8011 §4.2.1): the operation attributes in the order the standard
// requires, the job's copies, sides and media, and the document after the end-of-attributes tag.
func EncodePrintJob(printerURI string, o PrintOptions, doc []byte) []byte {
b := []byte{0x02, 0x00, 0x00, byte(ippPrintJob), 0, 0, 0, 1, ippOperation}
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
b = appendAttr(b, ippNameNoLang, "requesting-user-name", "brother")
b = appendAttr(b, ippNameNoLang, "job-name", o.Name)
b = appendAttr(b, ippMimeType, "document-format", o.Format)
b = append(b, ippJob)
b = appendAttr(b, ippInteger, "copies", string(binary.BigEndian.AppendUint32(nil, uint32(o.Copies))))
b = appendAttr(b, ippKeyword, "sides", o.Sides)
b = appendAttr(b, ippKeyword, "media", o.Media)
b = append(b, ippEnd)
return append(b, doc...)
}
// jobStates are the values RFC 8011 gives job-state.
var jobStates = map[int]string{3: "pending", 4: "pending-held", 5: "processing", 6: "processing-stopped", 7: "canceled", 8: "aborted", 9: "completed"}
// PrintResult is what a Print-Job answered: the job's id and state.
type PrintResult struct {
JobID int `json:"job-id"`
State string `json:"job-state"`
StateCode int `json:"job-state-code"`
Reasons []string `json:"job-state-reasons"`
}
// PrintResult reads a Print-Job answer; a failing status is an error naming it.
func (a IPPAnswer) PrintResult() (PrintResult, error) {
if a.Status >= 0x0100 {
return PrintResult{}, fmt.Errorf("the printer refused the print job with IPP status 0x%04x", a.Status)
}
id := a.Attributes["job-id"]
if len(id) == 0 {
return PrintResult{}, fmt.Errorf("the printer's answer to the print job names no job-id")
}
r := PrintResult{JobID: id[0].Integer, Reasons: a.texts("job-state-reasons")}
if r.Reasons == nil {
r.Reasons = []string{}
}
if st := a.Attributes["job-state"]; len(st) > 0 {
r.StateCode = st[0].Integer
r.State = jobStates[r.StateCode]
if r.State == "" {
r.State = fmt.Sprintf("unknown (%d)", r.StateCode)
}
}
return r, nil
}
// PrintJob sends the document to the printer's IPP service as a Print-Job, and answers the job's id and state.
func PrintJob(ctx context.Context, host string, o PrintOptions, doc []byte) (PrintResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), bytes.NewReader(EncodePrintJob(ippPrinterURI(host), o, doc)))
if err != nil {
return PrintResult{}, err
}
req.Header.Set("Content-Type", "application/ipp")
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
if err != nil {
return PrintResult{}, fmt.Errorf("the printer's IPP service does not answer: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return PrintResult{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return PrintResult{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
}
ans, err := DecodeIPP(body)
if err != nil {
return PrintResult{}, err
}
return ans.PrintResult()
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func ippFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "ipp", name))
if err != nil {
t.Fatal(err)
}
return b
}
// fakeIPP replays the captured Get-Printer-Attributes answer (testdata/ipp, 2026-10-20) and records what it
// was sent. It stands in for the printer's port 631: no test reaches a real printer.
func fakeIPP(t *testing.T, answer []byte) (url string, got *[]byte) {
t.Helper()
var sent []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sent, _ = io.ReadAll(r.Body)
if r.Header.Get("Content-Type") != "application/ipp" {
http.Error(w, "not ipp", http.StatusUnsupportedMediaType)
return
}
w.Header().Set("Content-Type", "application/ipp")
w.Write(answer)
}))
t.Cleanup(srv.Close)
return srv.URL, &sent
}
// 1453/AC5: the request the module encodes is byte for byte the captured one the printer answered.
func TestTheIPPRequestIsTheCapturedOne(t *testing.T) {
got := EncodeGetPrinterAttributes(ippPrinterURI("192.0.2.10"), statusAttributes)
if want := ippFixture(t, "get-printer-attributes.request"); !bytes.Equal(got, want) {
t.Errorf("request =\n%x\nwant\n%x", got, want)
}
}
// 1453/AC5: the captured IPP answer decodes to the printer's state, its reasons and its marker levels.
func TestTheCapturedIPPAnswerDecodes(t *testing.T) {
ans, err := DecodeIPP(ippFixture(t, "get-printer-attributes.answer"))
if err != nil {
t.Fatal(err)
}
st, err := ans.State()
if err != nil {
t.Fatal(err)
}
want := PrinterState{State: "idle", StateCode: 3, Reasons: []string{"toner-low-warning"},
MakeAndModel: "Brother MFC-L8390CDW series",
Markers: []Marker{{"BK", 40, "#000000"}, {"C", 10, "#00FFFF"}, {"M", 0, "#FF00FF"}, {"Y", 100, "#FFFF00"}}}
if !reflect.DeepEqual(st, want) {
t.Errorf("state = %+v\nwant %+v", st, want)
}
}
// 1453/AC5: an answer cut short, or one with a failing status, is an error and not a state.
func TestABrokenIPPAnswerIsAnError(t *testing.T) {
good := ippFixture(t, "get-printer-attributes.answer")
if _, err := DecodeIPP(good[:len(good)-20]); err == nil {
t.Errorf("a truncated answer decoded")
}
refused := append([]byte{}, good...)
refused[2], refused[3] = 0x04, 0x00
ans, err := DecodeIPP(refused)
if err != nil {
t.Fatal(err)
}
if _, err := ans.State(); err == nil || !strings.Contains(err.Error(), "0x0400") {
t.Errorf("error = %v", err)
}
}
// 1453/AC5: printer.status gives the IPP state and the eSCL state, replayed from the captures, and sends the
// captured request.
func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) {
url, sent := fakeIPP(t, ippFixture(t, "get-printer-attributes.answer"))
old := ippURL
ippURL = func(string) string { return url }
t.Cleanup(func() { ippURL = old })
f := newFakeScanner(t, nil)
tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now)
got, err := tool(t, tl, "printer.status").Run(nil)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["answers"] != true || m["state"] != "idle" || m["make-and-model"] != "Brother MFC-L8390CDW series" {
t.Errorf("answer = %v", m)
}
if r := m["state-reasons"].([]string); !reflect.DeepEqual(r, []string{"toner-low-warning"}) {
t.Errorf("reasons = %v", r)
}
if mk := m["markers"].([]Marker); len(mk) != 4 || mk[0] != (Marker{"BK", 40, "#000000"}) {
t.Errorf("markers = %v", mk)
}
if sc := m["scanner"].(map[string]any); sc["answers"] != true || sc["state"] != "Idle" || sc["adf-state"] != "ScannerAdfEmpty" {
t.Errorf("scanner = %v", sc)
}
if !bytes.Equal(*sent, ippFixture(t, "get-printer-attributes.request")) {
t.Errorf("the printer was sent %x", *sent)
}
}
// 1453/AC4 and AC5: a part that does not answer is answers false with why, the other part is still given; an
// unset printer or scanner is said by name and nothing is contacted.
func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) {
old := ippURL
ippURL = func(string) string { return "http://127.0.0.1:1/ipp/port1" }
t.Cleanup(func() { ippURL = old })
f := newFakeScanner(t, nil)
tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now)
got, err := tool(t, tl, "printer.status").Run(nil)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["answers"] != false || m["why"] == nil || m["scanner"].(map[string]any)["answers"] != true {
t.Errorf("answer = %v", m)
}
for _, c := range []struct{ js, want string }{
{`{"scanner":"` + f.base() + `"}`, "printer is not set"},
{`{"printer":"192.0.2.10"}`, "scanner is not set"},
{`{}`, "printer and scanner"},
} {
tl := tools(settingsFile(t, c.js), time.Now)
if _, err := tool(t, tl, "printer.status").Run(nil); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: error = %v, want %q", c.js, err, c.want)
}
}
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "device" {
os.Exit(device())
}
if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), time.Now)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// intArg reads a whole number out of a tool's arguments: a JSON number, which arrives as a float64.
func intArg(args map[string]any, name string) (int, error) {
v, ok := args[name]
if !ok {
return 0, nil
}
switch n := v.(type) {
case float64:
if n != float64(int(n)) {
return 0, fmt.Errorf("%s is a whole number", name)
}
return int(n), nil
case int:
return n, nil
}
return 0, fmt.Errorf("%s is a whole number", name)
}
func strArg(args map[string]any, name string) (string, error) {
v, ok := args[name]
if !ok {
return "", nil
}
s, isStr := v.(string)
if !isStr {
return "", fmt.Errorf("%s is a string", name)
}
return s, nil
}
// askFrom reads the arguments an ask takes out of a tool's.
func askFrom(args map[string]any) (Ask, error) {
var a Ask
var err error
for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256,
"data": &a.Data, "sides": &a.Sides, "media": &a.Media} {
if *dst, err = strArg(args, name); err != nil {
return a, err
}
}
for name, dst := range map[string]*int{"part": &a.Part, "parts": &a.Parts, "copies": &a.Copies} {
if *dst, err = intArg(args, name); err != nil {
return a, err
}
}
return a, nil
}
// tools are the scanner seat's and the printer seat's verbs, served as <seat>.<verb> (ADR 0467 clause 2, ADR 0468).
// The settings are read at each call, so a changed config value is the next call's. None opens a credential: what
// needs one is asked of the device process.
func tools(settingsFile string, now func() time.Time) []stdio.Tool {
service := func() (*Service, error) {
s, err := LoadSettings(settingsFile)
if err != nil {
return nil, err
}
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll}, nil
}
// ask is a tool that reads its arguments, and answers what the service gives.
ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
a, err := askFrom(args)
if err != nil {
return nil, err
}
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
return run(s, ctx, a)
}
}
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
num := func(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
return []stdio.Tool{
{
Name: "printer.upload",
Description: "Send one part of a document to the device process, to be printed later (printer.print with the upload id). " +
"Cut the document into parts of at most 700 KiB (700 KiB is the limit), number them from 1, and send each with the " +
"whole document's SHA-256. When the last part has come and the joined SHA-256 matches, the document is stored as " +
"uploads/<upload>.pdf or .jpg and the answer says complete; a wrong SHA-256 stores nothing. PDF and JPEG only.",
Input: map[string]any{"type": "object", "required": []string{"upload", "part", "parts", "sha256", "data"}, "properties": map[string]any{
"upload": str("an id of your choosing: letters, digits, - and _, at most 64"),
"part": num("this part's number, from 1"),
"parts": num("how many parts the document has"),
"sha256": str("the whole document's SHA-256, lower-case hex"),
"data": str("this part's bytes, base64"),
}},
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterUpload(ctx, a) }, 2*time.Minute),
},
{
Name: "printer.print",
Description: "Print a document on the printer: a scan kept in the bucket (object) or a completed upload (upload). The device " +
"process sends it as an IPP Print-Job in its own format (PDF or JPEG) and answers the job's id and state.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"object": str("a scan's key, as scan.added names it"),
"upload": str("a completed upload's id"),
"copies": num("1 to 99 (default 1)"),
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
"media": str("the paper's size name (default " + DefaultMedia + ")"),
}},
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterPrint(ctx, a) }, 2*time.Minute),
},
{
Name: "scanner.document",
Description: "One part of a scan kept in the bucket, by the key scan.added names (object): answers part, parts (how many), " +
"size, sha256 (of the whole document) and data (this part, base64). Join the parts and check the SHA-256.",
Input: map[string]any{"type": "object", "required": []string{"object", "part"}, "properties": map[string]any{
"object": str("the scan's key"),
"part": num("the part's number, from 1"),
}},
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) {
return s.ScanDocument(ctx, a.Object, a.Part)
}, 2*time.Minute),
},
{
Name: "printer.status",
Description: "The printer's state without a login: over IPP its state (idle, processing or stopped), its state " +
"reasons (such as toner-low-warning), its toner and other markers with their levels in percent (below 0 " +
"is unknown), and over eSCL the scanner's state and its document feeder's. A part that does not answer " +
"is said as answers false with why. Read-only.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return s.PrinterStatus(ctx)
},
},
{
Name: "printer.settings",
Description: "Read one page of the printer's admin site, read-only: the module's device process logs in with the " +
"secret admin-password (the operator's; it trusts the printer's pinned certificate alone), gets the page and logs " +
"out; this tool asks it and waits up to a minute for its answer. Answers the " +
"page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " +
"password field's value is never given. It sends nothing but the login and the logout, and writes no " +
"setting of the printer.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /general/status.html)"},
},
},
Run: func(args map[string]any) (any, error) {
page := ""
if v, ok := args["page"]; ok {
str, isStr := v.(string)
if !isStr {
return nil, fmt.Errorf("page is a string")
}
page = str
}
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return s.PrinterSettings(ctx, page)
},
},
{
Name: "scanner.scan",
Description: "Scan the glass of the configured network scanner: the device process cuts the document out of the platen's " +
"white, keeps it as a one-page PDF in the module's bucket under its sha256 and says scan.added on the bus; this " +
"tool then fetches it and files it in the scan folder. Waits up to three minutes. Answers the event's body: " +
"the object's key, its size and sha256, its real size in millimetres, the resolution the scanner really gave, " +
"whether it was cropped, and the file's path. The image itself is never sent.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute)
defer cancel()
return s.ScanNow(ctx)
},
},
{
Name: "scanner.status",
Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " +
"and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return s.ScanStatus(ctx)
},
},
{
Name: "scanner.list",
Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many scans (default 20)"},
},
},
Run: func(args map[string]any) (any, error) {
limit := 20
if v, ok := args["limit"]; ok {
f, isNum := v.(float64)
if i, isInt := v.(int); isInt {
f, isNum = float64(i), true
}
if !isNum || f < 1 || f > 1000 || f != float64(int(f)) {
return nil, fmt.Errorf("limit is a whole number from 1 to 1000")
}
limit = int(f)
}
s, err := service()
if err != nil {
return nil, err
}
return s.ScanList(limit)
},
},
}
}
@@ -0,0 +1,359 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"testing"
)
type manifest struct {
Requires []string `json:"requires"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
Emits []string `json:"emits"`
OwnSecrets map[string]map[string]any `json:"own-secrets"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
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
}
// 1440/AC9: the scan folder is no manifest resource, because a directory's path cannot take a config value
// (issue 1441, ADR 0464 clause 4): no resource's path names it, and no directory but the state one is declared.
func TestTheScanFolderIsNoManifestResource(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "${config:") || strings.Contains(p, "${setting:") {
t.Errorf("resource %v puts a config value in a path, which composition does not fill", r["id"])
}
if r["type"] == "directory" && r["id"] != "state" {
t.Errorf("directory %v: the code makes the scan folder", r["id"])
}
}
}
// 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named.
func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
m := readManifest(t)
// Declared in both places, as mesh-mail declares its sender's events: the module's own, and the device
// process's bus block (the controller refuses a process's emit the module does not declare as its own,
// ADR 0436 §1).
if !reflect.DeepEqual(m.Emits, []string{EventAdded}) {
t.Errorf("the module's own emits = %v, want [%s]", m.Emits, EventAdded)
}
bus, _ := resource(t, m, "device")["bus"].(map[string]any)
if !reflect.DeepEqual(bus["emits"], []any{EventAdded}) {
t.Errorf("the device process's bus emits = %v, want [%s]", bus["emits"], EventAdded)
}
var served []string
for _, tl := range tools("", nil) {
served = append(served, tl.Name)
}
got := append([]string{}, m.Tools...)
sort.Strings(got)
sort.Strings(served)
if !reflect.DeepEqual(got, served) {
t.Errorf("manifest tools = %v, the bundle serves %v", got, served)
}
// Every verb of a seat is a tool named <seat>.<verb>.
for _, st := range m.Claims {
for _, v := range st.Serves {
if !slices.Contains(served, st.Name+"."+v) {
t.Errorf("the seat %s serves %s, but no tool %s.%s is served", st.Name, v, st.Name, v)
}
}
}
}
// 1440/AC9: the config reaches the code as the one file, under the name the code reads, and every key the
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
m := readManifest(t)
keys := []string{"scanner", "scan-folder", "printer", "printer-certificate", "resolution", "color-mode", "crop"}
for _, k := range keys {
if _, ok := m.Config[k]; !ok {
t.Errorf("config does not declare %s", k)
}
}
if len(m.Config) != len(keys) {
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
}
// The printer's pins and addresses are written into the file as they are given, and match what the
// code's own validators accept (ADR 0467 clause 3).
fp := regexp.MustCompile(m.Config["printer-certificate"]["pattern"].(string))
host := regexp.MustCompile(m.Config["printer"]["pattern"].(string))
if !fp.MatchString(strings.Repeat("0A:", 31)+"0A") || fp.MatchString("0a:0a") || !host.MatchString("192.0.2.10") || host.MatchString("a b/c") {
t.Errorf("the printer patterns do not say a host and a SHA-256 fingerprint")
}
var taken []any
for _, r := range m.Resources {
if r["id"] == "settings" {
taken, _ = r["takes-config"].([]any)
}
}
if len(taken) != len(keys) {
t.Errorf("the settings file takes %v", taken)
}
env := ""
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
env, _ = a["env"].(map[string]any)["BROTHER_SETTINGS"].(string)
}
}
if env != "${dir:state}/settings.json" {
t.Errorf("BROTHER_SETTINGS = %q", env)
}
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
if err != nil {
t.Fatal(err)
}
var tpl map[string]any
if err := json.Unmarshal(raw, &tpl); err != nil {
t.Fatal(err)
}
// What the template and the manifest default say is what LoadSettings falls back to.
d, err := LoadSettings(settingsFile(t, string(raw)))
if err != nil {
t.Fatal(err)
}
for k, want := range map[string]any{"resolution": float64(d.Resolution), "color-mode": d.ColorMode, "crop": d.Crop} {
if m.Config[k]["default"] != want || tpl[k] != want {
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
}
}
for _, k := range []string{"scanner", "scan-folder", "printer", "printer-certificate"} {
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
t.Errorf("%s is the operator's: required and trusted", k)
}
}
}
// 1453/AC6: the manifest declares the scanner seat, held once for the mesh, and claims it, with the verbs
// ADR 0467 clause 2 names; the module serves each verb as the tool <seat>.<verb>.
func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) {
m := readManifest(t)
for _, want := range []seat{
{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list", "document"}},
// restart is round 2 of issue 1453, and is not declared yet.
{Name: "printer", Scope: "mesh", Serves: []string{"status", "settings", "print", "upload"}},
} {
for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} {
found := false
for _, s := range got {
if s.Name == want.Name {
found = true
if !reflect.DeepEqual(s, want) {
t.Errorf("%s: %s = %+v, want %+v", what, want.Name, s, want)
}
}
}
if !found {
t.Errorf("%s does not hold the %s seat", what, want.Name)
}
}
}
}
// 1453/AC6 (issue 1446): the three preferences are not trusted, so a person's own setting is not refused.
func TestThePreferencesAreNotTrusted(t *testing.T) {
m := readManifest(t)
for _, k := range []string{"resolution", "color-mode", "crop"} {
if v, ok := m.Config[k]["trusted"]; !ok || v != false {
t.Errorf("%s: trusted = %v, want false", k, v)
}
}
}
// 1453/AC6: the scan module is gone from the catalogue.
func TestTheScanModuleIsGone(t *testing.T) {
if _, err := os.Stat(filepath.Join("..", "..", "..", "scan")); !os.IsNotExist(err) {
t.Errorf("modules/scan still exists (err %v)", err)
}
}
// resource is the manifest's resource of this id.
func resource(t *testing.T, m manifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
// 1453/AC4 (ADR 0467 clause 4, ADR 0436 §1): the admin password is the module's own secret, issued by the
// operator (never generated), and owned by an account the module makes, brother-device: the tools' runner
// (the operator account) cannot read it, the device process (that account) can. The process's bus account is
// the own secret broker-device, owned by the same account.
func TestTheAdminPasswordIsTheOperatorsOwnSecretOwnedByTheAdminAccount(t *testing.T) {
m := readManifest(t)
s, ok := m.OwnSecrets["admin-password"]
if !ok {
t.Fatal("own-secrets does not declare admin-password")
}
if s["issued-by"] != "outside" {
t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"])
}
if s["owner"] != "brother-device" {
t.Errorf("owner = %v, want brother-device (never root or ${machine:account}: ADR 0436 §1)", s["owner"])
}
if p, _ := s["prompt"].(string); p != "the admin password of the Brother printer" {
t.Errorf("prompt = %q", p)
}
if b := m.OwnSecrets["broker-device"]; b["owner"] != "brother-device" || b["path"] != "${dir:state}/broker-device.secret" {
t.Errorf("broker-device = %v", b)
}
acct := resource(t, m, "account")
if acct["type"] != "user" || acct["name"] != "brother-device" || acct["shell"] != "/usr/bin/nologin" {
t.Errorf("account = %v", acct)
}
}
// 1453 (ADR 0467 clause 4): the device process runs as brother-device with the bus access of ADR 0436 §7: it
// consumes the work queue device-asks and writes the state device-answers, and is judged by its unit. The
// tools' bundle is given no password file.
func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
m := readManifest(t)
p := resource(t, m, "device")
if p["type"] != "process" || p["name"] != "brother-device" || p["user"] != "brother-device" || p["artifact"] != "tools" {
t.Errorf("process = %v", p)
}
if !reflect.DeepEqual(p["run"], []any{"./brother", "device"}) {
t.Errorf("run = %v", p["run"])
}
bus, _ := p["bus"].(map[string]any)
if !reflect.DeepEqual(bus["queues"], []any{"device-asks"}) || !reflect.DeepEqual(bus["state"], []any{"device-answers"}) {
t.Errorf("bus = %v", bus)
}
if h, _ := p["health"].(map[string]any); h["kind"] != "unit" {
t.Errorf("health = %v", p["health"])
}
env, _ := p["env"].(map[string]any)
want := map[string]any{
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"],
"BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"],
"BROTHER_STORE": "${dir:state}/store.json",
}
if !reflect.DeepEqual(env, want) {
t.Errorf("env = %v, want %v", env, want)
}
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
if _, has := a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"]; has {
t.Errorf("the tools' bundle is given the password file")
}
}
}
// The admin account reads the settings file and walks the state directory; neither holds a secret.
if r := resource(t, m, "settings"); r["mode"] != "0644" {
t.Errorf("settings mode = %v: the admin account must read it", r["mode"])
}
if r := resource(t, m, "state"); r["mode"] != "0711" {
t.Errorf("state mode = %v: the admin account must reach its files in it", r["mode"])
}
for _, r := range m.Resources {
if r["id"] != "device" {
if r["takes-config"] != nil {
for _, k := range r["takes-config"].([]any) {
if k == "admin-password" {
t.Errorf("the password is a config value")
}
}
}
}
}
}
// 1453 (ADR 0467 clause 4): the stream device-asks is a work queue like mesh-mail's mail-sends, and the
// answers are a state that expires.
func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
raw, _ := os.ReadFile(filepath.Join("..", "..", "module.json"))
var m struct {
Streams []map[string]any `json:"streams"`
State []any `json:"state"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.Streams) != 1 {
t.Fatalf("streams = %v", m.Streams)
}
st := m.Streams[0]
if st["name"] != "device-asks" || st["retention"] != "work-queue" || st["max-deliveries"] != float64(3) ||
!reflect.DeepEqual(st["events"], []any{EventAsked}) {
t.Errorf("stream = %v", st)
}
if st["duplicates"] != float64(24) {
t.Errorf("the stream has no duplicates window like mail-sends': %v", st["duplicates"])
}
if st["max-age-days"] == nil || st["max-mib"] == nil {
t.Errorf("the stream is bounded: %v", st)
}
if len(m.State) != 1 || m.State[0].(map[string]any)["name"] != AnswersState || m.State[0].(map[string]any)["ttl-seconds"] == nil {
t.Errorf("state = %v", m.State)
}
}
// 1453/AC8 (ADR 0468 clause 2): the module requires an s3-bucket as mesh-mail does, and its facts and key are
// written into store.json, a file only the device account can read; the tools' bundle is not told where it is.
func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) {
t.Errorf("requires = %v", m.Requires)
}
if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" {
t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets)
}
r := resource(t, m, "store")
if r["type"] != "file" || r["path"] != "${dir:state}/store.json" || r["mode"] != "0600" || r["owner"] != "brother-device" || r["template"] != "files/store.json" {
t.Errorf("store = %v", r)
}
mine, _ := os.ReadFile(filepath.Join("..", "..", "files", "store.json"))
theirs, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-mail", "files", "store.json"))
if err != nil || string(mine) != string(theirs) {
t.Errorf("files/store.json is not mesh-mail's (%v)", err)
}
p := resource(t, m, "device")
if env, _ := p["env"].(map[string]any); env["BROTHER_STORE"] != "${dir:state}/store.json" {
t.Errorf("the device process's BROTHER_STORE = %v", env["BROTHER_STORE"])
}
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
if _, has := a["env"].(map[string]any)["BROTHER_STORE"]; has {
t.Errorf("the tools' bundle is told where the bucket's key is")
}
}
}
}
+234
View File
@@ -0,0 +1,234 @@
package main
// The module's bucket (novox/hq ADR 0468 clause 2): each scan's PDF kept once, by its SHA-256, and each upload
// under uploads/, in the bucket the `minio` module provides this module through the `s3-bucket` provision. The
// mesh writes the bucket's facts and its key into one file, store.json, which only the device process (account
// brother-device) can read (BROTHER_STORE); this speaks S3 to it with the standard library alone, as mesh-mail's
// store does (modules/mesh-mail/cmd/mesh-mail/objects.go): a HEAD, a PUT and a GET, each signed with AWS
// Signature Version 4, path-style.
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
)
// Objects is the module's bucket.
type Objects interface {
// Bucket is the bucket's name, as an event names it.
Bucket() string
// Has says whether the key is kept.
Has(ctx context.Context, key string) (bool, error)
// Put keeps the bytes under the key.
Put(ctx context.Context, key, mediaType string, data []byte) error
// Get is the bytes kept under the key.
Get(ctx context.Context, key string) ([]byte, error)
}
// Store keeps the bytes under the key once: a key already kept is left as it is, since a key that names the
// content (the SHA-256 of a scan) names the same bytes.
func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) error {
has, err := o.Has(ctx, key)
if err != nil {
return err
}
if has {
return nil
}
return o.Put(ctx, key, mediaType, data)
}
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
type StoreFacts struct {
Scheme string `json:"scheme"`
At string `json:"at"`
Port string `json:"port"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Key string `json:"key"`
Secret string `json:"secret"`
}
// ReadStoreFacts reads the store's facts.
func ReadStoreFacts(path string) (StoreFacts, error) {
var f StoreFacts
if path == "" {
return f, errors.New("the module was started without its store's facts (BROTHER_STORE)")
}
raw, err := os.ReadFile(path)
if err != nil {
return f, fmt.Errorf("the store's facts cannot be read: %w", err)
}
if err := json.Unmarshal(raw, &f); err != nil {
return f, fmt.Errorf("the store's facts are not the module's: %v", err)
}
if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" {
return f, errors.New("the store's facts name no address, bucket or key")
}
if f.Scheme == "" {
f.Scheme = "http"
}
if f.Region == "" {
f.Region = "us-east-1"
}
return f, nil
}
// S3 is the module's bucket over S3.
type S3 struct {
Endpoint string // scheme://host[:port]
Name string
Region string
Access string
Secret string
Client *http.Client
Now func() time.Time
}
// NewS3 is the store the facts name.
func NewS3(f StoreFacts) *S3 {
host := f.At
if f.Port != "" {
host += ":" + f.Port
}
return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret,
Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now}
}
func (s *S3) Bucket() string { return s.Name }
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
if mediaType != "" {
req.Header.Set("Content-Type", mediaType)
}
req.ContentLength = int64(len(body))
Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC())
return s.Client.Do(req)
}
func (s *S3) Has(ctx context.Context, key string) (bool, error) {
resp, err := s.do(ctx, http.MethodHead, key, "", nil)
if err != nil {
return false, fmt.Errorf("the module's bucket cannot be reached: %w", err)
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
}
return false, fmt.Errorf("the module's bucket answered %s to asking for %s", resp.Status, key)
}
func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error {
resp, err := s.do(ctx, http.MethodPut, key, mediaType, data)
if err != nil {
return fmt.Errorf("the module's bucket cannot be reached: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("the module's bucket refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said)))
}
return nil
}
func (s *S3) Get(ctx context.Context, key string) ([]byte, error) {
resp, err := s.do(ctx, http.MethodGet, key, "", nil)
if err != nil {
return nil, fmt.Errorf("the module's bucket cannot be reached: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the module's bucket answered %s for %s", resp.Status, key)
}
return io.ReadAll(resp.Body)
}
// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is
// signed, with the payload's hash.
func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) {
payload := sha256.Sum256(body)
payloadHex := hex.EncodeToString(payload[:])
amzDate := now.Format("20060102T150405Z")
day := now.Format("20060102")
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHex)
headers := map[string]string{"host": req.URL.Host}
for k, v := range req.Header {
lk := strings.ToLower(k)
if lk == "user-agent" || lk == "content-length" || lk == "authorization" {
continue
}
headers[lk] = strings.TrimSpace(strings.Join(v, ","))
}
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonHeaders strings.Builder
for _, k := range names {
canonHeaders.WriteString(k + ":" + headers[k] + "\n")
}
signed := strings.Join(names, ";")
path := req.URL.EscapedPath()
if path == "" {
path = "/"
}
canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n")
scope := day + "/" + region + "/s3/aws4_request"
sum := sha256.Sum256([]byte(canonical))
toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:])
key := hmacOf([]byte("AWS4"+secret), day)
key = hmacOf(key, region)
key = hmacOf(key, "s3")
key = hmacOf(key, "aws4_request")
sig := hex.EncodeToString(hmacOf(key, toSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig)
}
func canonicalQuery(q url.Values) string {
keys := make([]string, 0, len(q))
for k := range q {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
vs := append([]string(nil), q[k]...)
sort.Strings(vs)
for _, v := range vs {
parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20"))
}
}
return strings.Join(parts, "&")
}
func hmacOf(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// memObjects is the bucket in memory, for the tests of what the device process keeps.
type memObjects struct {
mu sync.Mutex
kept map[string][]byte
puts int
}
func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} }
func (m *memObjects) Bucket() string { return "brother-test" }
func (m *memObjects) Has(_ context.Context, key string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.kept[key]
return ok, nil
}
func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
m.kept[key] = append([]byte(nil), data...)
m.puts++
return nil
}
func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.kept[key]
if !ok {
return nil, fmt.Errorf("%s is not kept", key)
}
return b, nil
}
func (m *memObjects) keys() []string {
m.mu.Lock()
defer m.mu.Unlock()
var k []string
for key := range m.kept {
k = append(k, key)
}
return k
}
// 1453/AC8: the S3 client puts, heads and gets path-style under a Signature Version 4 header, and Store keeps
// a key once.
func TestTheBucketIsSpokenToPathStyleSignedAndKeptOnce(t *testing.T) {
var mu sync.Mutex
objects := map[string][]byte{}
var puts int
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
auth = r.Header.Get("Authorization")
if !strings.HasPrefix(r.URL.Path, "/scans/") {
http.Error(w, "path-style: /<bucket>/<key>", http.StatusBadRequest)
return
}
key := strings.TrimPrefix(r.URL.Path, "/scans/")
switch r.Method {
case http.MethodPut:
b, _ := io.ReadAll(r.Body)
objects[key] = b
puts++
case http.MethodHead, http.MethodGet:
b, ok := objects[key]
if !ok {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
w.Write(b)
}
}
}))
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
at, port, _ := strings.Cut(host, ":")
s3 := NewS3(StoreFacts{Scheme: "http", At: at, Port: port, Bucket: "scans", Region: "us-east-1", Key: "AKID", Secret: "sek"})
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := Store(ctx, s3, "abc.pdf", "application/pdf", []byte("%PDF-x")); err != nil {
t.Fatal(err)
}
}
if puts != 1 {
t.Errorf("%d puts, want the one: a key is kept once", puts)
}
got, err := s3.Get(ctx, "abc.pdf")
if err != nil || string(got) != "%PDF-x" {
t.Errorf("Get = %q, %v", got, err)
}
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=AKID/") || !strings.Contains(auth, "SignedHeaders=") || strings.Contains(auth, "sek") {
t.Errorf("Authorization = %q", auth)
}
if _, err := s3.Get(ctx, "none.pdf"); err == nil {
t.Errorf("a key not kept was got")
}
}
// 1453: the store's facts are read from store.json, and one that names no bucket or key is refused by name.
func TestTheStoresFactsAreRead(t *testing.T) {
p := filepath.Join(t.TempDir(), "store.json")
os.WriteFile(p, []byte(`{"at":"minio.example","port":"9000","bucket":"brother","key":"k","secret":"s"}`), 0o600)
f, err := ReadStoreFacts(p)
if err != nil || f.Scheme != "http" || f.Region != "us-east-1" || f.Bucket != "brother" {
t.Errorf("facts = %+v, %v", f, err)
}
os.WriteFile(p, []byte(`{"at":"x"}`), 0o600)
if _, err := ReadStoreFacts(p); err == nil || !strings.Contains(err.Error(), "bucket") {
t.Errorf("error = %v", err)
}
if _, err := ReadStoreFacts(""); err == nil || !strings.Contains(err.Error(), "BROTHER_STORE") {
t.Errorf("error = %v", err)
}
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// Documents cross the bus in parts (novox/hq ADR 0468 clause 3): the bus refuses a message over 1 MiB, and a
// part travels base64-encoded inside a JSON message.
const (
// PartSize is how large the parts this module cuts are, and MaxPart the most raw bytes a part may hold; a part
// of more is refused. They are one: 700 KiB, so that a part's base64 text (about 933 KB) and the message around
// it stay under the bus's 1 MiB. (ADR 0468 clause 3.)
PartSize = 700 << 10
MaxPart = PartSize
// MostParts bounds how many parts one document has (a 100 MiB document is the most).
MostParts = 150
)
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// partCount is how many parts of PartSize hold size bytes: at least one.
func partCount(size int) int {
if size <= 0 {
return 1
}
return (size + PartSize - 1) / PartSize
}
// cutPart is part number part (counted from 1) of data, and how many parts there are.
func cutPart(data []byte, part int) ([]byte, int, error) {
n := partCount(len(data))
if part < 1 || part > n {
return nil, n, fmt.Errorf("part %d is out of range: the document has parts 1 to %d", part, n)
}
lo := (part - 1) * PartSize
hi := min(lo+PartSize, len(data))
return data[lo:hi], n, nil
}
// joinParts is the parts in order as one document, once its SHA-256 is the one named.
func joinParts(parts [][]byte, wantSHA string) ([]byte, error) {
var n int
for _, p := range parts {
n += len(p)
}
out := make([]byte, 0, n)
for _, p := range parts {
out = append(out, p...)
}
if got := sha256Hex(out); got != wantSHA {
return nil, fmt.Errorf("the joined parts' SHA-256 is %s, not the %s named: refused", got, wantSHA)
}
return out, nil
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"fmt"
"strings"
)
// Fingerprint is the SHA-256 of a certificate's DER bytes, as upper-case hex pairs joined by colons: the
// form the printer's own certificate page and the config value printer-certificate give (ADR 0467 clause 3).
func Fingerprint(der []byte) string {
sum := sha256.Sum256(der)
parts := make([]string, len(sum))
for i, b := range sum {
parts[i] = fmt.Sprintf("%02X", b)
}
return strings.Join(parts, ":")
}
// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be the one pinned, and
// any other certificate is refused, with both fingerprints in the message. It runs in the handshake, so a
// refused certificate is refused before one byte of a request (a password) is sent.
func pinned(want string) func([][]byte, [][]*x509.Certificate) error {
want = strings.ToUpper(strings.TrimSpace(want))
return func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", want)
}
if got := Fingerprint(raw[0]); got != want {
return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, want)
}
return nil
}
}
// pinnedTLS is the TLS configuration for the printer's admin page: the printer's certificate is self-signed,
// so no chain is verified; the pinned fingerprint is the whole of the trust (ADR 0467 clause 3).
func pinnedTLS(fingerprint string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
// The chain is not verified because the pinned fingerprint is checked in its place, below.
InsecureSkipVerify: true,
VerifyPeerCertificate: pinned(fingerprint),
}
}
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in
// .html, with no query, no dot-dot and no second slash at its start (which would name another host).
var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`)
// DefaultPage is the page printer.settings reads when none is asked for.
const DefaultPage = "/general/status.html"
// The asks the verbs put to the device process (ADR 0468 clause 1; the shape of ADR 0436's mail-sends): the tools
// never hold a credential, so each verb queues an ask on the work-queue stream device-asks, and the device process,
// an account of its own, answers under the ask's id in the state device-answers.
const (
// EventAsked is the event on the work queue; the stream device-asks carries it.
EventAsked = "device.asked"
// AsksStream is the work-queue stream's name on the bus: the manifest's local name device-asks, capitalised
// with its hyphen an underscore, as the controller composes a stream's own bus name.
AsksStream = "DEVICE_ASKS"
// AnswersState is the state the device process writes its answers in.
AnswersState = "device-answers"
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
// delivers its bus account as the own secret broker-device.
DeviceProcessID = "device"
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
AnswerWait = 60 * time.Second
ScanWait = 180 * time.Second
// StaleAfter is how old an ask may be when the device process takes it: older than twice the longest wait,
// nobody waits for it any more, so the device is not touched for it.
StaleAfter = 2 * ScanWait
)
// The operations of an ask.
const (
OpSettings = "settings"
OpScan = "scan"
OpDocument = "document"
OpUpload = "upload"
OpPrint = "print"
)
// Ask is what a verb queues: the operation, when it was asked, and the operation's own arguments.
type Ask struct {
ID string `json:"id"`
Op string `json:"op"`
At time.Time `json:"asked-at"`
Page string `json:"page,omitempty"` // settings
Object string `json:"object,omitempty"` // document, print: a scan's key
Upload string `json:"upload,omitempty"` // upload, print: an upload's id
Part int `json:"part,omitempty"` // document, upload: counted from 1
Parts int `json:"parts,omitempty"` // upload
SHA256 string `json:"sha256,omitempty"` // upload: the whole document's
Data string `json:"data,omitempty"` // upload: the part, base64
Copies int `json:"copies,omitempty"` // print
Sides string `json:"sides,omitempty"` // print
Media string `json:"media,omitempty"` // print
}
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
type AskBus interface {
Publish(id string, body any) error
Answer(id string) (json.RawMessage, error)
}
type sdkAskBus struct{}
func (sdkAskBus) Publish(id string, body any) error {
return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id})
}
func (sdkAskBus) Answer(id string) (json.RawMessage, error) {
e, err := stdio.State(AnswersState).Get(id)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}
// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them.
var (
answerWait = AnswerWait
scanWait = ScanWait
answerPoll = 500 * time.Millisecond
)
// askBus is the bus the tools use; a test puts a bus of its own in its place.
var askBus AskBus = sdkAskBus{}
func newAskID() string {
b := make([]byte, 12)
rand.Read(b)
return hex.EncodeToString(b)
}
// call queues the ask and waits for the device process's answer, at most wait. An answer that is an error is
// the error. Nothing here reads, opens or sends a credential (ADR 0436 §12).
func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[string]any, error) {
poll := s.AnswerPoll
if wait <= 0 {
wait = AnswerWait
}
if poll <= 0 {
poll = 500 * time.Millisecond
}
ask.ID, ask.At = newAskID(), s.Now().UTC()
if err := s.Asks.Publish(ask.ID, ask); err != nil {
return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, wait)
defer cancel()
for {
raw, err := s.Asks.Answer(ask.ID)
if err != nil {
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
}
if raw != nil {
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
}
if msg, bad := out["error"].(string); bad {
return nil, fmt.Errorf("%s", msg)
}
return out, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the device is slow (ask %s)",
wait, ask.ID)
case <-time.After(poll):
}
}
}
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
if !validPage(page) {
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
}
if err := s.Settings.MissingPrinter(); err != nil {
return nil, err
}
return s.call(ctx, Ask{Op: OpSettings, Page: page}, s.AnswerWait)
}
func validPage(page string) bool {
return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//")
}
var (
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// Sides are the values the printer's sides take.
var sidesValues = []string{"one-sided", "two-sided-long-edge", "two-sided-short-edge"}
// DefaultMedia is the paper printer.print takes when none is named.
const DefaultMedia = "iso_a4_210x297mm"
// checkDocument is what is wrong with an ask for a scan's part, or nil.
func checkDocument(a Ask) error {
if !scanKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
}
if a.Part < 1 {
return fmt.Errorf("part is a whole number from 1")
}
return nil
}
// checkUpload is what is wrong with an ask for an upload's part, or nil: the numbers, the SHA-256, the size.
func checkUpload(a Ask) error {
if !uploadPattern.MatchString(a.Upload) {
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
}
if a.Parts < 1 || a.Parts > MostParts {
return fmt.Errorf("parts is a whole number from 1 to %d, not %d", MostParts, a.Parts)
}
if a.Part < 1 || a.Part > a.Parts {
return fmt.Errorf("part %d is out of range: the upload has parts 1 to %d", a.Part, a.Parts)
}
if !sha256Pattern.MatchString(a.SHA256) {
return fmt.Errorf("sha256 is the whole document's SHA-256, 64 lower-case hex digits")
}
raw, err := base64.StdEncoding.DecodeString(a.Data)
if err != nil {
return fmt.Errorf("data is not base64: %v", err)
}
if len(raw) > MaxPart {
return fmt.Errorf("a part is at most %d bytes (700 KiB), not %d", MaxPart, len(raw))
}
return nil
}
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
func checkPrint(a *Ask) error {
if (a.Object == "") == (a.Upload == "") {
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
}
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
}
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
}
if a.Copies == 0 {
a.Copies = 1
}
if a.Copies < 1 || a.Copies > 99 {
return fmt.Errorf("copies is a whole number from 1 to 99, not %d", a.Copies)
}
if a.Sides == "" {
a.Sides = "one-sided"
}
if !slices.Contains(sidesValues, a.Sides) {
return fmt.Errorf("sides is one of %s, not %q", strings.Join(sidesValues, ", "), a.Sides)
}
if a.Media == "" {
a.Media = DefaultMedia
}
if !mediaPattern.MatchString(a.Media) {
return fmt.Errorf("media %q is not a media size name such as %s", a.Media, DefaultMedia)
}
return nil
}
// ScanDocument asks for one part of a scan kept in the bucket.
func (s *Service) ScanDocument(ctx context.Context, object string, part int) (map[string]any, error) {
a := Ask{Op: OpDocument, Object: object, Part: part}
if err := checkDocument(a); err != nil {
return nil, err
}
return s.call(ctx, a, s.AnswerWait)
}
// PrinterUpload sends one part of a document to be printed later, and says whether the upload is complete.
func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, error) {
a.Op = OpUpload
if err := checkUpload(a); err != nil {
return nil, err
}
return s.call(ctx, a, s.AnswerWait)
}
// PrinterPrint asks the device process to print a kept scan or a completed upload.
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
a.Op = OpPrint
if err := checkPrint(&a); err != nil {
return nil, err
}
if s.Settings.Printer == "" {
return nil, unsetError("printer")
}
return s.call(ctx, a, s.AnswerWait)
}
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
// scanner is not set is said by name, and nothing is contacted.
func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) {
var unset []string
if s.Settings.Printer == "" {
unset = append(unset, "printer")
}
if s.Settings.Scanner == "" {
unset = append(unset, "scanner")
}
if len(unset) > 0 {
return nil, unsetError(unset...)
}
out := map[string]any{}
if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil {
out["answers"] = false
out["why"] = err.Error()
} else {
out["answers"] = true
out["state"] = st.State
out["state-code"] = st.StateCode
out["state-reasons"] = st.Reasons
out["make-and-model"] = st.MakeAndModel
out["markers"] = st.Markers
}
if es, err := NewClient(s.Settings.Scanner).Status(ctx); err != nil {
out["scanner"] = map[string]any{"answers": false, "why": err.Error()}
} else {
out["scanner"] = map[string]any{"answers": true, "state": es.State, "adf-state": es.AdfState}
}
return out, nil
}
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"brother/cmd/brother/internal/secret"
)
// 1453/AC3: printer.settings logs in by the field name the form gives, sends the password from the secret
// file, follows the session cookie, and returns the page's title, links and form fields; a password field's
// value is never returned, and the session is logged out. The page is SYNTHETIC (testdata/admin/
// synthetic-settings.html): the real pages arrive in round 2.
func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) {
f := newFakePrinter(t, "Qw9z3", "p4ss word")
_, run := printerTools(t, f, "p4ss word")
got, err := run(map[string]any{"page": "/general/other.html"})
if err != nil {
t.Fatal(err)
}
if len(f.posted) != 1 || f.posted[0].Get("Qw9z3") != "p4ss word" {
t.Errorf("the login posted %v", f.posted)
}
raw, _ := json.Marshal(got)
out := string(raw)
var a struct {
Title string
Links []Link
Forms []Form
Logout string
}
json.Unmarshal(raw, &a)
if a.Title != "Synthetic settings" {
t.Errorf("title = %q", a.Title)
}
if len(a.Links) != 2 || a.Links[1] != (Link{Href: "/general/status.html", Text: "Status"}) {
t.Errorf("links = %v", a.Links)
}
if len(a.Forms) != 1 || a.Forms[0].Action != "/general/other.html" || a.Forms[0].Method != "post" {
t.Fatalf("forms = %+v", a.Forms)
}
want := map[string]Field{
"lock": {Name: "lock", Type: "checkbox", Value: "on", Label: "Scan lock"},
"node": {Name: "node", Type: "text", Value: "BRN-SYNTH", Label: "Name"},
"pw": {Name: "pw", Type: "password", Value: "", Label: "Secret"},
"note": {Name: "note", Type: "textarea", Value: "hello & bye"},
"token": {Name: "token", Type: "hidden", Value: "t1"},
}
for _, fl := range a.Forms[0].Fields {
if fl.Name == "mode" {
if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || strings.Join(fl.Options, ",") != "a,m" {
t.Errorf("mode = %+v", fl)
}
continue
}
w, ok := want[fl.Name]
if !ok || fl.Name != w.Name || fl.Type != w.Type || fl.Value != w.Value || fl.Label != w.Label {
t.Errorf("field %+v, want %+v", fl, w)
}
delete(want, fl.Name)
}
if len(want) != 0 {
t.Errorf("fields missing: %v", want)
}
if strings.Contains(out, "shouldnotshow") {
t.Errorf("a password field's value was returned: %s", out)
}
if a.Logout != "logged out" || f.loggedInNow() != 0 {
t.Errorf("logout = %q, sessions open %d", a.Logout, f.loggedInNow())
}
// Read-only: the only form posted is the login.
if len(f.posted) != 1 {
t.Errorf("forms posted: %v", f.posted)
}
}
// 1453/AC3: with no page asked for, the status page is read.
func TestPrinterSettingsDefaultsToTheStatusPage(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
_, run := printerTools(t, f, "pw")
got, err := run(nil)
if err != nil {
t.Fatal(err)
}
if got.(map[string]any)["title"] != "Synthetic status" {
t.Errorf("answer = %v", got)
}
}
// 1453/AC4: a wrong password is said as a failed login.
func TestPrinterSettingsSaysAFailedLogin(t *testing.T) {
f := newFakePrinter(t, "B1891", "right")
_, run := printerTools(t, f, "wrong")
if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("error = %v", err)
}
}
// 1453/AC4: printer or printer-certificate not set is said by name by the tool, nothing is queued and the
// printer is not contacted.
func TestPrinterSettingsSaysWhichConfigIsUnsetAndQueuesNothing(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
for _, c := range []struct{ name, settings, want string }{
{"no printer", `{"printer-certificate":"` + f.print() + `"}`, "printer is not set"},
{"no certificate", `{"printer":"` + f.host() + `"}`, "printer-certificate is not set"},
{"neither", `{}`, "printer and printer-certificate"},
} {
r := newRig(t, c.settings, "pw")
_, err := r.run(nil)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: error = %v, want it to say %q", c.name, err, c.want)
}
if r.bus.asks() != 0 {
t.Errorf("%s: an ask was queued", c.name)
}
}
if f.connsNow() != 0 || len(f.requests) != 0 {
t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests)
}
}
// 1453/AC4: the secret not given is said by name, by the device process, and the printer is not contacted.
func TestPrinterSettingsSaysTheSecretIsNotGivenAndContactsNothing(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
_, run := printerTools(t, f, "")
if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
t.Errorf("no secret file: error = %v", err)
}
// An empty file is not given either.
r := newRig(t, printerSettingsJSON(f), "")
r.worker.Password = func() (string, error) {
p := filepath.Join(t.TempDir(), "e")
os.WriteFile(p, []byte("\n"), 0o600)
return secret.Read(p)
}
if _, err := r.run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
t.Errorf("empty secret: error = %v", err)
}
if f.connsNow() != 0 || len(f.requests) != 0 {
t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests)
}
}
// 1453: a page that is not a page of the admin site is refused before anything is queued, and by the admin
// process too if one reaches it.
func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
r := newRig(t, printerSettingsJSON(f), "pw")
for _, p := range []string{"general/status.html", "/general/status", "//evil.example/x.html", "/a/../b.html", "/a.html?x=1", "/a b.html"} {
if _, err := r.run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") {
t.Errorf("page %q: error = %v", p, err)
}
if _, err := r.worker.read(t.Context(), p); err == nil || !strings.Contains(err.Error(), "not a page") {
t.Errorf("the device process, page %q: error = %v", p, err)
}
}
if _, err := r.run(map[string]any{"page": 5}); err == nil {
t.Errorf("a page that is no string was accepted")
}
if r.bus.asks() != 0 || f.connsNow() != 0 {
t.Errorf("%d asks queued, the printer contacted %d times", r.bus.asks(), f.connsNow())
}
}
// 1453: nobody consuming the queue is said as the device process not answering, naming the wait.
func TestPrinterSettingsSaysWhenTheAdminProcessDoesNotAnswer(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
r := newRig(t, printerSettingsJSON(f), "pw")
r.bus.worker = nil
answerWait = 100 * time.Millisecond
_, err := r.run(nil)
if err == nil || !strings.Contains(err.Error(), "device process did not answer") {
t.Fatalf("error = %v", err)
}
if r.bus.asks() != 1 {
t.Errorf("%d asks queued", r.bus.asks())
}
}
// 1453: the device process terminates an ask it cannot read, drops one nobody waits for any more without
// contacting the printer, acknowledges one already answered, and asks for redelivery when it cannot write its
// answer.
func TestTheAdminWorkerSettlesEachAsk(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
r := newRig(t, printerSettingsJSON(f), "pw")
w := r.worker
if act, _ := w.Handle([]byte("not json"), nil); act != actionTerm {
t.Errorf("an unreadable ask: action %v, want term", act)
}
old, _ := json.Marshal(Ask{ID: "old", Op: OpSettings, Page: DefaultPage, At: fixedNow.Add(-StaleAfter - time.Second)})
if act, _ := w.Handle(old, nil); act != actionAck || r.answers.get("old") != nil || f.connsNow() != 0 {
t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.connsNow())
}
fresh, _ := json.Marshal(Ask{ID: "a1", Op: OpSettings, Page: DefaultPage, At: fixedNow})
if act, _ := w.Handle(fresh, nil); act != actionAck || r.answers.get("a1") == nil {
t.Errorf("a fresh ask: action %v", act)
}
conns := f.connsNow()
if act, _ := w.Handle(fresh, nil); act != actionAck {
t.Errorf("an ask already answered: action %v, want ack", act)
}
_ = conns
r.answers.fail = fmt.Errorf("the bus is away")
again, _ := json.Marshal(Ask{ID: "a2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
if act, delay := w.Handle(again, nil); act != actionRetry || delay <= 0 {
t.Errorf("an answer that cannot be written: action %v delay %v, want retry", act, delay)
}
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"brother/cmd/brother/internal/secret"
)
// fixedNow is the time the tools and the device worker of a rig read.
var fixedNow = time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local)
// memAnswers is the state device-answers, in memory.
type memAnswers struct {
mu sync.Mutex
m map[string]json.RawMessage
// fail, when set, is the error every Create answers.
fail error
}
func (a *memAnswers) Create(key string, v any) (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fail != nil {
return 0, a.fail
}
if _, ok := a.m[key]; ok {
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
}
if a.m == nil {
a.m = map[string]json.RawMessage{}
}
raw, _ := json.Marshal(v)
if len(raw) > 1<<20 {
return 0, fmt.Errorf("an answer of %d bytes is over the bus's 1 MiB", len(raw))
}
a.m[key] = raw
return 1, nil
}
func (a *memAnswers) get(key string) json.RawMessage {
a.mu.Lock()
defer a.mu.Unlock()
return a.m[key]
}
// fakeAskBus is the bus between the tools and the device process: an ask published on it is handed to the
// worker, as the work queue hands it to the device process, and the worker's answers are what the tools read.
type fakeAskBus struct {
worker *deviceWorker // nil: nobody consumes the queue
answers *memAnswers
mu sync.Mutex
published []Ask
}
func (b *fakeAskBus) Publish(id string, body any) error {
raw, _ := json.Marshal(body)
if len(raw) > 1<<20 {
return fmt.Errorf("a message of %d bytes is over the bus's 1 MiB", len(raw))
}
var ask Ask
json.Unmarshal(raw, &ask)
b.mu.Lock()
b.published = append(b.published, ask)
b.mu.Unlock()
if b.worker != nil {
go b.worker.Handle(raw, nil)
}
return nil
}
func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil }
func (b *fakeAskBus) asks() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.published)
}
func (b *fakeAskBus) ops() []string {
b.mu.Lock()
defer b.mu.Unlock()
var o []string
for _, a := range b.published {
o = append(o, a.Op)
}
return o
}
// printerRig is the tools, the bus and the device worker over a settings file, with the secret in a file the way
// the mesh writes it (a trailing newline included; an empty password writes none) and the bucket in memory.
type printerRig struct {
tl []stdio.Tool
run func(args map[string]any) (any, error) // printer.settings
bus *fakeAskBus
worker *deviceWorker
answers *memAnswers
objects *memObjects
said *[]said
mu *sync.Mutex
}
// call runs a tool by name.
func (r *printerRig) call(t *testing.T, name string, args map[string]any) (any, error) {
t.Helper()
return tool(t, r.tl, name).Run(args)
}
func newRig(t *testing.T, settings, password string) *printerRig {
t.Helper()
secretFile := filepath.Join(t.TempDir(), "admin-password.secret")
if password != "" {
if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
file := settingsFile(t, settings)
answers, objects := &memAnswers{}, newMemObjects()
var out []said
var mu sync.Mutex
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(file) },
Password: func() (string, error) { return secret.Read(secretFile) },
Objects: func() (Objects, error) { return objects, nil },
Emit: func(body map[string]any, id string) error {
mu.Lock()
defer mu.Unlock()
_, err := objects.Get(t.Context(), body["object"].(string))
out = append(out, said{body, id, err == nil})
return nil
},
Answers: answers,
Node: "shanks",
Now: func() time.Time { return fixedNow },
}
bus := &fakeAskBus{worker: w, answers: answers}
old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll
askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond
t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll })
tl := tools(file, func() time.Time { return fixedNow })
return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu}
}
func printerSettingsJSON(f *fakePrinter) string {
return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`
}
func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) {
t.Helper()
js := printerSettingsJSON(f)
return js, newRig(t, js, password).run
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"time"
)
// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device
// process. Consumers declare brother.scan.added; it
// travels as mesh.mod.brother.event.scan.added. It names the object and never carries the image.
const EventAdded = "scan.added"
// Service is what the tools do, over one reading of the settings. The device process's own scan is Capture's.
type Service struct {
Settings Settings
// Node is this machine's name, for the event.
Node string
Now func() time.Time
// Asks is how the verbs reach the device process, which alone holds a credential.
Asks AskBus
// AnswerWait is how long a verb waits for the device process's answer (ScanWait for a scan); AnswerPoll how
// often it looks.
AnswerWait, ScanWait, AnswerPoll time.Duration
}
// ScanNow asks the device process to scan, crop, build the PDF, keep it in the bucket and say scan.added, waits for
// the stored scan's key, fetches its parts through the device process, joins them, checks their SHA-256 and files
// the PDF in the scan folder (temp and rename, no overwrite). It answers the event's body and the path. Nothing is
// asked before scanner and scan-folder are known to be set (1440/AC7); the tool never reads a credential.
func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
ans, err := s.call(ctx, Ask{Op: OpScan}, s.ScanWait)
if err != nil {
return nil, err
}
key, _ := ans["key"].(string)
sum, _ := ans["sha256"].(string)
parts, _ := ans["parts"].(float64)
if key == "" || sum == "" || parts < 1 || parts > MostParts {
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan: %v", ans)
}
got := make([][]byte, 0, int(parts))
for i := 1; i <= int(parts); i++ {
p, err := s.fetchPart(ctx, key, i)
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but its part %d could not be fetched: %w", key, i, err)
}
got = append(got, p)
}
pdf, err := joinParts(got, sum)
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s: %w", key, err)
}
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
if err != nil {
at = s.Now()
}
path, err := File(s.Settings.Folder, pdf, at.Local())
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but could not be filed: %w", key, err)
}
body := map[string]any{}
for k, v := range ans {
if k != "key" && k != "size" && k != "parts" {
body[k] = v
}
}
body["path"] = path
return body, nil
}
// fetchPart is one part of a stored object, asked of the device process and decoded.
func (s *Service) fetchPart(ctx context.Context, object string, part int) ([]byte, error) {
ans, err := s.call(ctx, Ask{Op: OpDocument, Object: object, Part: part}, s.AnswerWait)
if err != nil {
return nil, err
}
data, _ := ans["data"].(string)
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, fmt.Errorf("the part is not base64: %w", err)
}
return raw, nil
}
// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an
// answer, not an error: that is how the module's health is read.
func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
st, err := NewClient(s.Settings.Scanner).Status(ctx)
if err != nil {
return map[string]any{"answers": false, "why": err.Error()}, nil
}
return map[string]any{"answers": true, "state": st.State, "adf-state": st.AdfState}, nil
}
// ScanList is the newest filed scans, newest first.
func (s *Service) ScanList(limit int) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
scans, err := List(s.Settings.Folder, limit)
if err != nil {
return nil, err
}
return map[string]any{"folder": s.Settings.Folder, "scans": scans}, nil
}
@@ -5,7 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"image/jpeg"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -18,13 +18,14 @@ import (
type said struct {
body map[string]any
id string
// seen is whether the filed scan was there when it was said.
// seen is whether the scan was kept in the bucket when it was said.
seen bool
}
func service(t *testing.T, f *fakeScanner, s Settings) (*Service, *[]said) {
// scanRig is the tools, the device worker and a bucket in memory over a settings file for the fake scanner and a
// scan folder of the test's own, with the real defaults laid under what s sets.
func scanRig(t *testing.T, f *fakeScanner, s Settings) (*printerRig, string) {
t.Helper()
var out []said
if s.Scanner == "" {
s.Scanner = f.base()
}
@@ -37,24 +38,30 @@ func service(t *testing.T, f *fakeScanner, s Settings) (*Service, *[]said) {
if s.ColorMode == "" {
s.ColorMode = "color"
}
return &Service{Settings: s, Node: "shanks", Now: func() time.Time { return time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) },
Emit: func(body map[string]any, id string) error {
_, err := os.Stat(body["path"].(string))
out = append(out, said{body, id, err == nil})
return nil
}}, &out
js, _ := json.Marshal(map[string]any{"scanner": s.Scanner, "scan-folder": s.Folder, "resolution": s.Resolution,
"color-mode": s.ColorMode, "crop": s.Crop})
return newRig(t, string(js), "pw"), s.Folder
}
func (r *printerRig) scan(t *testing.T) (map[string]any, error) {
t.Helper()
got, err := r.call(t, "scanner.scan", map[string]any{})
if err != nil {
return nil, err
}
return got.(map[string]any), nil
}
// 1440/AC2: asked 300 dpi, the scanner gave 200: the event's resolution and millimetres, and the PDF's page,
// are the image's own.
func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
svc, _ := service(t, f, Settings{Crop: false, Resolution: 300})
body, err := svc.ScanNow(context.Background())
r, _ := scanRig(t, f, Settings{Crop: false, Resolution: 300})
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if body["resolution"] != 200 {
if body["resolution"] != float64(200) {
t.Errorf("resolution = %v, want the image's 200 (300 was asked)", body["resolution"])
}
if body["width-mm"] != 215.4 || body["height-mm"] != 279.8 {
@@ -66,100 +73,127 @@ func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) {
}
}
// 1440/AC6: one scan.added, after the rename, with every key and never the image, under the sha256.
func TestScanNowEmitsOneEventAfterTheRenameUnderTheSha256(t *testing.T) {
// 1440/AC6 and 1453/AC9: one scan.added, said by the device process after the PDF is kept in the bucket under its
// sha256, with every key, the object's key and never the image; the tool then files the PDF.
func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
svc, out := service(t, f, Settings{Crop: true})
body, err := svc.ScanNow(context.Background())
r, folder := scanRig(t, f, Settings{Crop: true})
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if len(*out) != 1 {
t.Fatalf("%d events, want 1", len(*out))
out := *r.said
if len(out) != 1 {
t.Fatalf("%d events, want 1", len(out))
}
ev := (*out)[0]
ev := out[0]
if !ev.seen {
t.Error("the event was said before the file was in place")
t.Error("the event was said before the PDF was kept in the bucket")
}
for _, k := range []string{"path", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} {
for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} {
if _, ok := ev.body[k]; !ok {
t.Errorf("the event lacks %s", k)
}
}
if len(ev.body) != 10 {
t.Errorf("the event has %d keys, want the ten named", len(ev.body))
t.Errorf("the event has %d keys, want the ten named (no path: filing is the tool's)", len(ev.body))
}
pdf, err := os.ReadFile(ev.body["path"].(string))
pdf, err := os.ReadFile(body["path"].(string))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(pdf)
if ev.body["sha256"] != hex.EncodeToString(sum[:]) || ev.id != ev.body["sha256"] {
t.Errorf("sha256 %v / message id %v, want the file's %x", ev.body["sha256"], ev.id, sum)
want := hex.EncodeToString(sum[:])
if ev.body["sha256"] != want || ev.id != want || ev.body["object"] != want+".pdf" {
t.Errorf("sha256 %v / message id %v / object %v, want the file's %s", ev.body["sha256"], ev.id, ev.body["object"], want)
}
if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) {
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
}
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
t.Errorf("body = %v", ev.body)
t.Errorf("event = %v", ev.body)
}
if want := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local).UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want)
}
if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 10 {
t.Error("the body holds more than the ten keys")
if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 11 {
t.Errorf("the tool's answer has %d keys, want the event's ten and the path", len(body))
}
if _, has := body["key"]; has {
t.Errorf("the device process's own answer keys leak into the tool's: %v", body)
}
// The cropped scan is the receipt, about 80-84 mm wide (1440/AC3).
if w := ev.body["width-mm"].(float64); w < 80 || w > 84 {
t.Errorf("width = %v mm", w)
}
img, _ := os.ReadDir(filepath.Dir(ev.body["path"].(string)))
if len(img) != 1 {
t.Errorf("folder holds %d files, want the one scan", len(img))
files, _ := os.ReadDir(folder)
if len(files) != 1 || filepath.Join(folder, files[0].Name()) != body["path"] {
t.Errorf("folder holds %v, want the one scan", files)
}
}
// 1440/AC6: a scanner that refuses writes nothing, says nothing, and the error says why.
func TestAScannerRefusalWritesAndSaysNothing(t *testing.T) {
// 1453/AC9: the same scan twice is one object in the bucket: a key is kept once.
func TestTheSameScanTwiceIsOneObject(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: true})
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
f.mu.Lock()
f.documents = 0
f.mu.Unlock()
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
if r.objects.puts != 1 || len(r.objects.keys()) != 1 {
t.Errorf("%d puts, objects %v: want the one", r.objects.puts, r.objects.keys())
}
}
// 1440/AC6: a scanner that refuses keeps nothing, writes nothing, says nothing, and the error says why.
func TestAScannerRefusalKeepsWritesAndSaysNothing(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.refuse = 503
svc, out := service(t, f, Settings{})
_, err := svc.ScanNow(context.Background())
r, folder := scanRig(t, f, Settings{})
_, err := r.scan(t)
if err == nil || !strings.Contains(err.Error(), "503") {
t.Fatalf("error = %v, want the refusal said", err)
}
if len(*out) != 0 {
t.Errorf("%d events after a refusal", len(*out))
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v after a refusal", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) {
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists after a refusal: %v", err)
}
}
// 1440/AC6: a job that gives no page (the second NextDocument's 404) is the same.
func TestAJobThatGivesNoPageWritesAndSaysNothing(t *testing.T) {
func TestAJobThatGivesNoPageKeepsWritesAndSaysNothing(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.noPage = true
svc, out := service(t, f, Settings{})
if _, err := svc.ScanNow(context.Background()); err == nil || !strings.Contains(err.Error(), "404") {
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %v", err)
}
if len(*out) != 0 {
t.Errorf("%d events", len(*out))
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) {
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists: %v", err)
}
}
// 1440/AC6: an image that is no JPEG (a scanner that sent something else) files nothing.
func TestAnImageThatIsNotAJPEGFilesNothing(t *testing.T) {
// 1440/AC6: an image that is no JPEG (a scanner that sent something else) keeps and files nothing.
func TestAnImageThatIsNotAJPEGKeepsAndFilesNothing(t *testing.T) {
f := newFakeScanner(t, []byte("not an image"))
svc, out := service(t, f, Settings{})
if _, err := svc.ScanNow(context.Background()); err == nil {
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err == nil {
t.Fatal("a non-image was filed")
}
if len(*out) != 0 {
t.Errorf("%d events", len(*out))
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) {
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists: %v", err)
}
}
@@ -167,11 +201,11 @@ func TestAnImageThatIsNotAJPEGFilesNothing(t *testing.T) {
// 1440/AC11: the first scan into a folder that is not there makes it.
func TestTheFirstScanMakesTheFolder(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
svc, _ := service(t, f, Settings{})
if _, err := svc.ScanNow(context.Background()); err != nil {
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
info, err := os.Stat(svc.Settings.Folder)
info, err := os.Stat(folder)
if err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("folder %v %v", info, err)
}
@@ -207,16 +241,16 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
} {
folder := filepath.Join(t.TempDir(), "scans")
js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder)
var out []said
tl := tools(settingsFile(t, js), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now)
for _, name := range []string{"scan_now", "scan_status", "scan_list"} {
_, err := tool(t, tl, name).Run(map[string]any{})
r := newRig(t, js, "pw")
out := *r.said
for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} {
_, err := tool(t, r.tl, name).Run(map[string]any{})
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want)
}
}
if f.posts != 0 || f.documents != 0 || len(out) != 0 {
t.Errorf("the scanner was asked or an event said: %d posts, %d documents, %d events", f.posts, f.documents, len(out))
if f.posts != 0 || f.documents != 0 || len(out) != 0 || r.bus.asks() != 0 || len(r.objects.keys()) != 0 {
t.Errorf("the scanner was asked, an ask queued or an event said: %d posts, %d documents, %d events, %d asks", f.posts, f.documents, len(out), r.bus.asks())
}
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder was made: %v", err)
@@ -226,8 +260,8 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
// 1440/AC7: the settings file is not there at all.
func TestAMissingSettingsFileIsSaid(t *testing.T) {
tl := tools(filepath.Join(t.TempDir(), "none.json"), nil, time.Now)
if _, err := tool(t, tl, "scan_status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
tl := tools(filepath.Join(t.TempDir(), "none.json"), time.Now)
if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
t.Fatalf("error = %v", err)
}
}
@@ -235,7 +269,7 @@ func TestAMissingSettingsFileIsSaid(t *testing.T) {
// 1440/AC8: scan_status from the captured idle status; a scanner that does not answer is answers false.
func TestScanStatusSaysStateAndAdfState(t *testing.T) {
f := newFakeScanner(t, nil)
svc, _ := service(t, f, Settings{})
svc := &Service{Settings: Settings{Scanner: f.base(), Folder: "/x"}}
got, err := svc.ScanStatus(context.Background())
if err != nil {
t.Fatal(err)
@@ -259,8 +293,8 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) {
os.WriteFile(p, []byte("x"), 0o644)
os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour))
}
tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), nil, time.Now)
got, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(5)})
tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), time.Now)
got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)})
if err != nil {
t.Fatal(err)
}
@@ -268,7 +302,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) {
if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" {
t.Fatalf("scans = %v", scans)
}
if _, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(0)}); err == nil {
if _, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(0)}); err == nil {
t.Error("limit 0 accepted")
}
}
@@ -277,18 +311,13 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) {
func TestScanNowToolScansWithTheFileSettings(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
folder := filepath.Join(t.TempDir(), "scans")
var out []said
tl := tools(settingsFile(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`),
func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now)
got, err := tool(t, tl, "scan_now").Run(map[string]any{})
r := newRig(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`, "pw")
got, err := r.call(t, "scanner.scan", map[string]any{})
if err != nil {
t.Fatal(err)
}
if len(out) != 1 || got.(map[string]any)["cropped"] != true {
t.Fatalf("%d events, body %v", len(out), got)
}
if _, err := jpeg.DecodeConfig(bytes.NewReader(fixture(t, "scan.jpg"))); err != nil {
t.Fatal(err)
if len(*r.said) != 1 || got.(map[string]any)["cropped"] != true {
t.Fatalf("%d events, body %v", len(*r.said), got)
}
}
@@ -1,4 +1,4 @@
// Package main is the scan module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a
// Package main is the brother module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a
// network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus.
// A scan happens when a tool is called; the module runs no process of its own.
package main
@@ -10,7 +10,7 @@ import (
"strings"
)
// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348).
// Settings are the module's seven config values, as the mesh writes them into settings.json (ADR 0348).
type Settings struct {
// Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
Scanner string
@@ -21,16 +21,21 @@ type Settings struct {
Resolution int
// ColorMode is "color" or "gray".
ColorMode string
// Printer is the device's host (with a port if it is not 443), such as 192.0.2.10: the host of its admin
// page and its IPP service (ADR 0467 clause 3).
Printer string
// PrinterCertificate is the SHA-256 fingerprint of the one certificate the device's admin page may serve.
PrinterCertificate string
// Crop says whether the document is cut out of the platen's white.
Crop bool
}
// LoadSettings reads settings.json (the file named by SCAN_SETTINGS) and lays the defaults under it. A missing
// LoadSettings reads settings.json (the file named by BROTHER_SETTINGS) and lays the defaults under it. A missing
// or unreadable file is an error that names it; an unset scanner or folder is Missing's to say.
func LoadSettings(path string) (Settings, error) {
s := Settings{Resolution: 300, ColorMode: "color", Crop: true}
if path == "" {
return s, fmt.Errorf("SCAN_SETTINGS names no settings file: the module's config does not reach the code")
return s, fmt.Errorf("BROTHER_SETTINGS names no settings file: the module's config does not reach the code")
}
raw, err := os.ReadFile(path)
if err != nil {
@@ -52,6 +57,12 @@ func LoadSettings(path string) (Settings, error) {
if v, ok := in["color-mode"].(string); ok && v != "" {
s.ColorMode = v
}
if v, ok := in["printer"].(string); ok {
s.Printer = strings.TrimSpace(v)
}
if v, ok := in["printer-certificate"].(string); ok {
s.PrinterCertificate = strings.TrimSpace(v)
}
if v, ok := in["crop"].(bool); ok {
s.Crop = v
}
@@ -71,6 +82,29 @@ func (s Settings) Missing() error {
if len(unset) == 0 {
return nil
}
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the scan module on this node",
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(unset, " and "))
}
// unsetError says which config values are not set.
func unsetError(names ...string) error {
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(names, " and "))
}
// MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not:
// nothing is contacted before that (1453/AC4).
func (s Settings) MissingPrinter() error {
var unset []string
if s.Printer == "" {
unset = append(unset, "printer")
}
if s.PrinterCertificate == "" {
unset = append(unset, "printer-certificate")
}
if len(unset) == 0 {
return nil
}
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(unset, " and "))
}
@@ -0,0 +1,11 @@
HTTP/1.1 200 OK
Cache-Control: no-store
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Length: 2974
Content-Type: text/html
Content-Language: en-gb
Connection: close
X-XSS-Protection: 1; mode=block
Pragma: no-cache
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/general/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2"><a href="/home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto&#32;Refresh&#32;Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Please&#32;Login</h2></div><div id="pageContents"><div class="contentsGroup"><p class="noteMessage">To&#32;Access&#32;this&#32;page&#32;you&#32;are&#32;required&#32;to&#32;Login,&#32;please&#32;Login.</p></div></div></div></div></div></div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic settings</title></head><body><a href="/home/logout.html">Logout</a><a href="/general/status.html">Status</a><form method="post" action="/general/other.html"><table><tr><td><label for="lock">Scan&#32;lock</label></td><td><input type="checkbox" id="lock" name="lock" value="on"/></td></tr><tr><td><label for="name">Name</label></td><td><input type="text" id="name" name="node" value="BRN-SYNTH"/></td></tr><tr><td><label for="mode">Mode</label></td><td><select id="mode" name="mode"><option value="a">Auto</option><option value="m" selected="selected">Manual</option></select></td></tr><tr><td><label>Secret<input type="password" name="pw" value="shouldnotshow"/></label></td></tr><tr><td><textarea name="note">hello &amp; bye</textarea></td></tr></table><input type="hidden" name="token" value="t1"/><input type="submit" value="Save"/></form></body></html>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic status</title></head><body><div id="LogInOutBox"><a href="/home/logout.html">Logout</a></div><div id="pageTitle"><h2>Status</h2></div><div><a href="/general/other.html">Other&#32;page</a><a href="/net/net/airprint.html">AirPrint</a></div></body></html>

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

@@ -1,6 +1,8 @@
{
"scanner": "",
"scan-folder": "",
"printer": "",
"printer-certificate": "",
"resolution": 300,
"color-mode": "color",
"crop": true
+9
View File
@@ -0,0 +1,9 @@
{
"scheme": "${bound:s3-bucket:scheme}",
"at": "${bound:s3-bucket:at}",
"port": "${bound:s3-bucket:port}",
"bucket": "${bound:s3-bucket:bucket}",
"region": "${bound:s3-bucket:region}",
"key": "${bound:s3-bucket:as}",
"secret": "${secret:s3-bucket}"
}
+22
View File
@@ -0,0 +1,22 @@
module brother
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+27
View File
@@ -0,0 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+260
View File
@@ -0,0 +1,260 @@
{
"module": "brother",
"version": "1",
"requires": [
"s3-bucket"
],
"binds": {
"s3-bucket": "${dir:state}/store-binding.json"
},
"secrets": {
"s3-bucket": "${dir:state}/store.secret"
},
"config": {
"scanner": {
"type": "string",
"description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.",
"pattern": "^https?://.+",
"trusted": true,
"required": true
},
"scan-folder": {
"type": "string",
"description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.",
"pattern": "^/",
"trusted": true,
"required": true
},
"printer": {
"type": "string",
"description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.",
"pattern": "^[A-Za-z0-9.:-]+$",
"trusted": true,
"required": true
},
"printer-certificate": {
"type": "string",
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons. The module trusts that certificate alone and refuses any other; the operator's, with no default.",
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}$",
"trusted": true,
"required": true
},
"resolution": {
"type": "number",
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
"enum": [
100,
200,
300,
600
],
"trusted": false,
"default": 300
},
"color-mode": {
"type": "string",
"description": "Whether the scan is in colour or in grays: color or gray.",
"enum": [
"color",
"gray"
],
"trusted": false,
"default": "color"
},
"crop": {
"type": "boolean",
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
"trusted": false,
"default": true
}
},
"own-secrets": {
"admin-password": {
"path": "${dir:state}/admin-password.secret",
"issued-by": "outside",
"prompt": "the admin password of the Brother printer",
"owner": "brother-device"
},
"broker-device": {
"path": "${dir:state}/broker-device.secret",
"owner": "brother-device"
}
},
"streams": [
{
"name": "device-asks",
"events": [
"device.asked"
],
"max-age-days": 1,
"max-mib": 8,
"retention": "work-queue",
"max-deliveries": 3,
"duplicates": 24
}
],
"state": [
{
"name": "device-answers",
"ttl-seconds": 600
}
],
"seats": [
{
"name": "scanner",
"scope": "mesh",
"serves": [
"scan",
"status",
"list",
"document"
]
},
{
"name": "printer",
"scope": "mesh",
"serves": [
"status",
"settings",
"print",
"upload"
]
}
],
"claims": [
{
"name": "scanner",
"scope": "mesh",
"serves": [
"scan",
"status",
"list",
"document"
]
},
{
"name": "printer",
"scope": "mesh",
"serves": [
"status",
"settings",
"print",
"upload"
]
}
],
"emits": [
"scan.added"
],
"tools": [
"scanner.scan",
"scanner.status",
"scanner.list",
"scanner.document",
"printer.status",
"printer.settings",
"printer.print",
"printer.upload"
],
"resources": [
{
"id": "account",
"type": "user",
"name": "brother-device",
"shell": "/usr/bin/nologin"
},
{
"id": "state",
"type": "directory",
"mode": "0711",
"owner": "${machine:account}",
"place": ".",
"unknown": "program"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"owner": "${machine:account}",
"mode": "0644",
"takes-config": [
"scanner",
"scan-folder",
"printer",
"printer-certificate",
"resolution",
"color-mode",
"crop"
],
"merge": "json",
"template": "files/settings.json",
"trusted": true
},
{
"id": "store",
"type": "file",
"path": "${dir:state}/store.json",
"owner": "brother-device",
"mode": "0600",
"template": "files/store.json"
},
{
"id": "device",
"type": "process",
"name": "brother-device",
"artifact": "tools",
"run": [
"./brother",
"device"
],
"user": "brother-device",
"env": {
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret",
"BROTHER_BROKER": "${dir:state}/broker-device.secret",
"BROTHER_STORE": "${dir:state}/store.json"
},
"restart-on": [
"settings",
"store"
],
"bus": {
"queues": [
"device-asks"
],
"state": [
"device-answers"
],
"emits": [
"scan.added"
]
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/brother",
"binary": "brother",
"loads": [
"brother"
],
"env": {
"BROTHER_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
-66
View File
@@ -1,66 +0,0 @@
# scan — scan a document, crop it, file it, say so
Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that
speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF
in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called:
the module runs no process of its own, and it runs as the node's operator account.
## What a scan does
1. Asks the scanner for its glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once.
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why.
## Config
| key | meaning | default |
|---|---|---|
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
| `color-mode` | `color` or `gray` | `color` |
| `crop` | cut the document out of the lid's white | true |
The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes
from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every tool says which and
writes and asks nothing.
The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code
makes it (mode 0755) when it files the first scan.
## Tools
| tool | does |
|---|---|
| `scan_now` | scans, crops, files and emits; answers the event's body |
| `scan_status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
| `scan_list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
## The event
`scan.added`, on the wire `mesh.mod.scan.event.scan.added`; a consumer declares `scan.scan.added`. Its body:
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
and `scan_list` only reads.
## Tests
The unit tests use the captured answers of a real scanner (`cmd/scan/testdata/escl`, captured 2026-10-20) served
by a test HTTP server; no test reaches a scanner.
-92
View File
@@ -1,92 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools(os.Getenv("SCAN_SETTINGS"), emit, time.Now)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// emit says scan.added on the bus, under the file's sha256 as its message id.
func emit(body map[string]any, id string) error {
return stdio.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id})
}
// tools are the three tools. The settings are read at each call, so a changed config value is the next
// call's.
func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool {
service := func() (*Service, error) {
s, err := LoadSettings(settingsFile)
if err != nil {
return nil, err
}
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say}, nil
}
return []stdio.Tool{
{
Name: "scan_now",
Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " +
"file it as a one-page PDF in the scan folder and say scan.added on the bus. Answers the event's body: " +
"the file's path, its size and sha256, its real size in millimetres, the resolution the scanner " +
"really gave, whether it was cropped. The image itself is never sent.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
return s.ScanNow(context.Background())
},
},
{
Name: "scan_status",
Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " +
"and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return s.ScanStatus(ctx)
},
},
{
Name: "scan_list",
Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many scans (default 20)"},
},
},
Run: func(args map[string]any) (any, error) {
limit := 20
if v, ok := args["limit"]; ok {
f, isNum := v.(float64)
if i, isInt := v.(int); isInt {
f, isNum = float64(i), true
}
if !isNum || f < 1 || f > 1000 || f != float64(int(f)) {
return nil, fmt.Errorf("limit is a whole number from 1 to 1000")
}
limit = int(f)
}
s, err := service()
if err != nil {
return nil, err
}
return s.ScanList(limit)
},
},
}
}
-118
View File
@@ -1,118 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
type manifest struct {
Emits []string `json:"emits"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
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
}
// 1440/AC9: the scan folder is no manifest resource, because a directory's path cannot take a config value
// (issue 1441, ADR 0464 clause 4): no resource's path names it, and no directory but the state one is declared.
func TestTheScanFolderIsNoManifestResource(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "${config:") || strings.Contains(p, "${setting:") {
t.Errorf("resource %v puts a config value in a path, which composition does not fill", r["id"])
}
if r["type"] == "directory" && r["id"] != "state" {
t.Errorf("directory %v: the code makes the scan folder", r["id"])
}
}
}
// 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named.
func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Emits, []string{EventAdded}) {
t.Errorf("emits = %v, want [%s]", m.Emits, EventAdded)
}
var served []string
for _, tl := range tools("", nil, nil) {
served = append(served, tl.Name)
}
if !reflect.DeepEqual(m.Tools, served) {
t.Errorf("manifest tools = %v, the bundle serves %v", m.Tools, served)
}
}
// 1440/AC9: the config reaches the code as the one file, under the name the code reads, and every key the
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
m := readManifest(t)
keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"}
for _, k := range keys {
if _, ok := m.Config[k]; !ok {
t.Errorf("config does not declare %s", k)
}
}
if len(m.Config) != len(keys) {
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
}
var taken []any
for _, r := range m.Resources {
if r["id"] == "settings" {
taken, _ = r["takes-config"].([]any)
}
}
if len(taken) != len(keys) {
t.Errorf("the settings file takes %v", taken)
}
env := ""
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
env, _ = a["env"].(map[string]any)["SCAN_SETTINGS"].(string)
}
}
if env != "${dir:state}/settings.json" {
t.Errorf("SCAN_SETTINGS = %q", env)
}
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
if err != nil {
t.Fatal(err)
}
var tpl map[string]any
if err := json.Unmarshal(raw, &tpl); err != nil {
t.Fatal(err)
}
// What the template and the manifest default say is what LoadSettings falls back to.
d, err := LoadSettings(settingsFile(t, string(raw)))
if err != nil {
t.Fatal(err)
}
for k, want := range map[string]any{"resolution": float64(d.Resolution), "color-mode": d.ColorMode, "crop": d.Crop} {
if m.Config[k]["default"] != want || tpl[k] != want {
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
}
}
for _, k := range []string{"scanner", "scan-folder"} {
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
t.Errorf("%s is the operator's: required and trusted", k)
}
}
}
-103
View File
@@ -1,103 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"time"
)
// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare scan.scan.added; it
// travels as mesh.mod.scan.event.scan.added. It names the file and never carries the image.
const EventAdded = "scan.added"
// Emitter says an event on the bus under a message id, so a repeated publish is kept to one (ADR 0436).
type Emitter func(body map[string]any, messageID string) error
// Service is what the tools do, over one reading of the settings.
type Service struct {
Settings Settings
// Node is this machine's name, for the event.
Node string
Now func() time.Time
Emit Emitter
}
func mm(pixels, dpi int) float64 {
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10
}
// ScanNow scans the glass, crops, files and says so. Nothing is written and nothing is said when the scanner
// does not give a page; the error says why. The event follows the rename, once (1440/AC6).
func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
at := s.Now()
scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode)
if err != nil {
return nil, err
}
// The resolution is the image's own, never the one asked (1440/AC2).
dpi, err := Density(scan)
if err != nil {
return nil, err
}
cut, err := Crop(scan, s.Settings.Crop)
if err != nil {
return nil, err
}
pdf, err := PDF(cut.JPEG, dpi)
if err != nil {
return nil, err
}
path, err := File(s.Settings.Folder, pdf, at)
if err != nil {
return nil, err
}
sum := sha256.Sum256(pdf)
id := hex.EncodeToString(sum[:])
body := map[string]any{
"path": path,
"node": s.Node,
"bytes": len(pdf),
"sha256": id,
"width-mm": mm(cut.Width, dpi),
"height-mm": mm(cut.Height, dpi),
"resolution": dpi,
"color-mode": s.Settings.ColorMode,
"cropped": cut.Cropped,
"scanned-at": at.UTC().Format(time.RFC3339),
}
if err := s.Emit(body, id); err != nil {
return nil, fmt.Errorf("the scan is filed at %s, but %s could not be said on the bus: %w", path, EventAdded, err)
}
return body, nil
}
// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an
// answer, not an error: that is how the module's health is read.
func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
st, err := NewClient(s.Settings.Scanner).Status(ctx)
if err != nil {
return map[string]any{"answers": false, "why": err.Error()}, nil
}
return map[string]any{"answers": true, "state": st.State, "adf-state": st.AdfState}, nil
}
// ScanList is the newest filed scans, newest first.
func (s *Service) ScanList(limit int) (map[string]any, error) {
if err := s.Settings.Missing(); err != nil {
return nil, err
}
scans, err := List(s.Settings.Folder, limit)
if err != nil {
return nil, err
}
return map[string]any{"folder": s.Settings.Folder, "scans": scans}, nil
}
-5
View File
@@ -1,5 +0,0 @@
module scan
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
-102
View File
@@ -1,102 +0,0 @@
{
"module": "scan",
"version": "1",
"emits": [
"scan.added"
],
"config": {
"scanner": {
"type": "string",
"description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.",
"pattern": "^https?://.+",
"trusted": true,
"required": true
},
"scan-folder": {
"type": "string",
"description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.",
"pattern": "^/",
"trusted": true,
"required": true
},
"resolution": {
"type": "number",
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
"enum": [
100,
200,
300,
600
],
"default": 300
},
"color-mode": {
"type": "string",
"description": "Whether the scan is in colour or in grays: color or gray.",
"enum": [
"color",
"gray"
],
"default": "color"
},
"crop": {
"type": "boolean",
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
"default": true
}
},
"tools": [
"scan_now",
"scan_status",
"scan_list"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "${machine:account}",
"place": ".",
"unknown": "program"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"owner": "${machine:account}",
"mode": "0600",
"takes-config": [
"scanner",
"scan-folder",
"resolution",
"color-mode",
"crop"
],
"merge": "json",
"template": "files/settings.json"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/scan",
"binary": "scan",
"loads": [
"scan"
],
"env": {
"SCAN_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}