diff --git a/.gitignore b/.gitignore index ea861d09..9a589bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ 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 +modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go diff --git a/checks/owned/main.go b/checks/owned/main.go index 09512d5d..67762f06 100644 --- a/checks/owned/main.go +++ b/checks/owned/main.go @@ -160,8 +160,14 @@ func Check(root string) ([]Finding, error) { } dirs := map[string]string{} for _, r := range m.Resources { - if r.ID != "" && r.Path != "" { + switch { + case r.ID != "" && r.Path != "": dirs[r.ID] = r.Path + case r.ID != "" && r.Type == "directory": + // A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is + // the module's own, so a path under it belongs to this module and to no other's directory of the same + // id (issue 1484: two runs-as modules each keep a .ssh in their own state directory). + dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID } } for _, r := range m.Resources { diff --git a/checks/owned/main_test.go b/checks/owned/main_test.go index 0882ba47..6426244f 100644 --- a/checks/owned/main_test.go +++ b/checks/owned/main_test.go @@ -130,3 +130,18 @@ func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) { t.Fatalf("refused %+v", found) } } + +// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's +// directory of the same id, and a path under it, are not inside it. +func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"first", "second"} { + manifest(t, root, name, mod(name, + `{"id":"state","type":"directory","place":".","unknown":"program"}`, + dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`), + `{"id":"conf","type":"file","path":"${dir:ssh}/config"}`)) + } + if found := check(t, root); len(found) != 0 { + t.Fatalf("refused %+v", found) + } +} diff --git a/checks/sshdir/main.go b/checks/sshdir/main.go index 0b7eb4ab..babf2c5c 100644 --- a/checks/sshdir/main.go +++ b/checks/sshdir/main.go @@ -5,7 +5,7 @@ // go run . // // Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a -// place, a volume. Exits 1 on a finding. +// place, a volume; a path inside a directory of the module's own (`${dir:}`) is not ~/.ssh. Exits 1 on a finding. package main import ( @@ -15,6 +15,7 @@ import ( "path/filepath" "regexp" "sort" + "strings" ) // SSHModule is the one module allowed to name a path under ~/.ssh. @@ -60,7 +61,7 @@ func Check(root string) ([]Finding, error) { if module == SSHModule { continue } - walk(m, "", func(key, value string) { + walk(m, "", dirsOf(m), func(key, value string) { out = append(out, Finding{Module: module, Key: key, Value: value}) }) } @@ -68,7 +69,7 @@ func Check(root string) ([]Finding, error) { } // walk calls found for every string under v, outside Prose, that names a path under ~/.ssh. -func walk(v any, key string, found func(key, value string)) { +func walk(v any, key string, dirs map[string]string, found func(key, value string)) { switch x := v.(type) { case map[string]any: keys := make([]string, 0, len(x)) @@ -78,20 +79,68 @@ func walk(v any, key string, found func(key, value string)) { sort.Strings(keys) for _, k := range keys { if !Prose[k] { - walk(x[k], k, found) + walk(x[k], k, dirs, found) } } case []any: for _, e := range x { - walk(e, key, found) + walk(e, key, dirs, found) } case string: - if underSSH.MatchString(x) { + // A path under a directory the mesh places for the module (${dir:} of a directory with a place and no path, + // as a runs-as module's home) is that account's own .ssh, not the operator's ~/.ssh that ssh-client sweeps + // (issue 1484, ADR 0466 §4). Any other ${dir:} is resolved to the path its resource declares and judged. + if resolved, placed := resolveDir(x, dirs); !placed && underSSH.MatchString(resolved) { found(key, x) } } } +var ofDir = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9_-]*)\}`) + +// placedDir marks a directory the mesh places for the module: it has no path of its own. +const placedDir = "\x00placed" + +// dirsOf are the module's directories by id: the path each declares, or placedDir for one with a place and no path. +func dirsOf(m any) map[string]string { + dirs := map[string]string{} + obj, _ := m.(map[string]any) + list, _ := obj["resources"].([]any) + for _, r := range list { + res, _ := r.(map[string]any) + id, _ := res["id"].(string) + path, _ := res["path"].(string) + switch { + case id == "": + case path != "": + dirs[id] = path + case res["type"] == "directory": + dirs[id] = placedDir + } + } + return dirs +} + +// resolveDir follows a leading ${dir:} through the module's directories. It answers the path with the references +// resolved, and whether it ends under a placed directory. +func resolveDir(x string, dirs map[string]string) (string, bool) { + for i := 0; i < 8; i++ { + m := ofDir.FindStringSubmatch(x) + if m == nil { + return x, false + } + to, ok := dirs[m[1]] + if !ok { + return x, false + } + if to == placedDir { + return x, true + } + x = to + strings.TrimPrefix(x, m[0]) + } + return x, false +} + func main() { root := "." if len(os.Args) > 1 { diff --git a/checks/sshdir/main_test.go b/checks/sshdir/main_test.go index ca78b3e7..a7df3332 100644 --- a/checks/sshdir/main_test.go +++ b/checks/sshdir/main_test.go @@ -37,6 +37,36 @@ func TestAnotherModuleNamingSSHIsRefused(t *testing.T) { } } +// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's); +// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused. +func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) { + root := t.TempDir() + manifest(t, root, "builder", `{"module":"builder","resources":[{"id":"state","type":"directory","place":"."},{"id":"ssh-dir","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`) + manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Module != "other" { + t.Fatalf("refused %+v; want only other", found) + } +} + +// Issue 1484 review: only a directory the mesh places is the module's own home. A ${dir:} that resolves to the +// operator's home is judged by the path it resolves to. +func TestADirResolvingToTheOperatorsHomeIsStillRefused(t *testing.T) { + root := t.TempDir() + manifest(t, root, "escape", `{"module":"escape","resources":[{"id":"h","type":"directory","path":"${machine:account-home}","unknown":"module"},{"path":"${dir:h}/.ssh/config"}]}`) + manifest(t, root, "placed", `{"module":"placed","resources":[{"id":"state","type":"directory","place":".","unknown":"program"},{"id":"s","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:s}/config"}]}`) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Module != "escape" { + t.Fatalf("refused %+v; want only escape", found) + } +} + func TestTheCatalogueHasNone(t *testing.T) { found, err := Check("../..") if err != nil { diff --git a/modules/brother/README.md b/modules/brother/README.md new file mode 100644 index 00000000..98328181 --- /dev/null +++ b/modules/brother/README.md @@ -0,0 +1,130 @@ +# 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 /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 certificate the admin page may serve, `AA:BB:...`; several, separated by commas, when it serves more than one (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/.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 and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; 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 one of those in `printer-certificate` and refuses any other, in the handshake, so no +password is sent to it; the error names the served and the pinned fingerprints. Give each as 32 upper-case hex pairs +joined by colons, several separated by commas. The printer serves two self-signed certificates for one name, an +ECDSA one and an RSA one, and which it serves depends on the signature algorithms the client offers, so pin both +(issue 1503): read them with `openssl s_client -connect :443 -sigalgs ECDSA+SHA256` and `-sigalgs +RSA-PSS+SHA256`, each piped to `openssl x509 -noout -fingerprint -sha256`. + +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 `.pdf` and an upload as `uploads/.`. + +### 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. diff --git a/modules/brother/cmd/brother/admin.go b/modules/brother/cmd/brother/admin.go new file mode 100644 index 00000000..79114abe --- /dev/null +++ b/modules/brother/cmd/brother/admin.go @@ -0,0 +1,212 @@ +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 +} + +// loginStart is where a login starts: the root, which the printer answers with a redirect to the page it serves +// its login form on (/home/status.html). Asking for a page behind the login instead is answered by a redirect +// to an error page, and a loginurl naming a page this firmware lacks is a 404 after the login (issue 1514). +const loginStart = "/" + +// 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 { + if resp.StatusCode == http.StatusNotFound { + return nil, nil, ¬FoundError{Path: resp.Request.URL.Path} + } + 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 +} + +func hasField(f *Form, name string) bool { + for _, fl := range f.Fields { + if fl.Name == name { + return true + } + } + return false +} + +// Login asks for the printer's login page, reads its login form, and posts the form's own fields with the +// password in the field the form names and the form's loginurl set to the default page, which every firmware has; the page asked for is a request of its own afterwards. 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(loginStart), 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", at.Path) + } + 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.Scheme != "https" || target.Host != a.Host { + return fmt.Errorf("the login form posts to %s, not to https on the printer %s: the password is not sent", target.Scheme+"://"+target.Host, a.Host) + } + vals := url.Values{} + for _, f := range form.Fields { + if f.Type == "hidden" { + vals.Set(f.Name, f.Value) + } + } + if _, ok := vals["loginurl"]; ok { + vals.Set("loginurl", DefaultPage) + } + vals.Set(pw.Name, a.Password) + answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals) + if err != nil { + // The post may have opened a session before its answer failed: end it if there is one. + if home, hat, herr := a.Get(context.Background(), DefaultPage); herr == nil { + a.Logout(context.Background(), home, hat) + } + return fmt.Errorf("the login was not answered: %w", err) + } + // A login page again is a form with a password field and the loginurl field; a page that was asked for may + // hold a password field of its own. + if again, _ := passwordForm(ParsePage(answer)); again != nil && hasField(again, "loginurl") { + 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" +} + +// notFoundError is the printer's 404 for a page. +type notFoundError struct{ Path string } + +func (e *notFoundError) Error() string { return "the printer answered 404 Not Found for " + e.Path } diff --git a/modules/brother/cmd/brother/admin_test.go b/modules/brother/cmd/brother/admin_test.go new file mode 100644 index 00000000..bd786582 --- /dev/null +++ b/modules/brother/cmd/brother/admin_test.go @@ -0,0 +1,302 @@ +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) + // The login page the printer serves at /home/status.html carries that page as its loginurl (captured live + // 2026-10-20, testdata/admin/live/home_status_html.body); the page it serves at /etc/loginerror.html carries + // the page that was asked for. + homeLogin := strings.ReplaceAll(login, `value="/general/status.html"`, `value="/home/status.html"`) + synthetic := adminFixture(t, "synthetic-status.html") + // This firmware has no /general/status.html behind the login (issue 1514): asking for it is a 404. + f.pages["/home/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(homeLogin)) + 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: "/"}) + // Like Brother's form, a good login goes on to the page named in loginurl. + http.Redirect(w, r, r.PostForm.Get("loginurl"), http.StatusFound) + }) + 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() + if r.URL.Path == "/" { + http.Redirect(w, r, "/home/status.html", http.StatusMovedPermanently) + return + } + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + // Captured: a page behind the login is answered by a redirect to the login page whether it exists or not. + if !authed(r) { + http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound) + return + } + body, ok := f.pages[r.URL.Path] + if !ok { + http.NotFound(w, r) + 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") != DefaultPage { + 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, DefaultPage) + 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("nothing"), 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(`
`)) + })) + 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) + } +} + +// 1514 review: a page the printer does not have leaves no session open, and the error says the page is missing, +// not that the login failed. +func TestAMissingPageLeavesNoSessionOpenAndSaysSo(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + _, run := printerTools(t, f, "pw") + _, err := run(map[string]any{"page": "/general/nope.html"}) + if err == nil || !strings.Contains(err.Error(), "the printer has no page /general/nope.html") { + t.Fatalf("error = %v, want the printer has no page", err) + } + if strings.Contains(err.Error(), "login was not answered") { + t.Errorf("a missing page is said to be a failed login: %v", err) + } + if f.loggedInNow() != 0 { + t.Errorf("%d sessions are still open on the printer", f.loggedInNow()) + } +} + +// 1514 review: the password is posted to https on the printer's host only. A form whose action is plain http +// on the same host, or a root that redirects to plain http, is refused and nothing is posted. +func TestThePasswordIsNotPostedOverPlainHTTP(t *testing.T) { + var posted int + mux := http.NewServeMux() + page := func(action string) string { + return `
` + } + var host string + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posted++ + } + w.Write([]byte(page("http://" + host + "/home/status.html"))) + }) + srv := httptest.NewTLSServer(mux) + 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(), "password is not sent") { + t.Errorf("error = %v, want the plain http action refused", err) + } + if posted != 0 { + t.Errorf("%d posts reached the server", posted) + } +} diff --git a/modules/brother/cmd/brother/capture.go b/modules/brother/cmd/brother/capture.go new file mode 100644 index 00000000..fbf1bb30 --- /dev/null +++ b/modules/brother/cmd/brother/capture.go @@ -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), + } +} diff --git a/modules/brother/cmd/brother/crop.go b/modules/brother/cmd/brother/crop.go new file mode 100644 index 00000000..62554ecb --- /dev/null +++ b/modules/brother/cmd/brother/crop.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/jpeg" +) + +// Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner +// and the lid's white everywhere else. The lid's level is measured where no document can be (the corner +// farthest from the origin), a pixel is document when it is darker than the lid by a margin, and the rows and +// columns holding enough document pixels give the box (ADR 0464). +const ( + // lidBlock is the side of the corner block the lid's level is measured on. + lidBlock = 300 + // darker is how much darker than the lid a pixel is document: paper on a white lid is a level or two + // under it, an edge shadow far more. + darker = 1 + // rowShare is the fraction of a row's (column's) length that must be document for the row (column) to + // count: specks of dust stay under it. + rowShare = 0.05 + // margin is the pixels kept around the box. + margin = 6 + // wholeShare is the fraction of the platen a box may fill before it is no crop at all. + wholeShare = 0.9 + // jpegQuality is the quality the cropped image is encoded with. + jpegQuality = 90 +) + +// gray is the pixel's level, 0 to 255. +func grayAt(img image.Image, x, y int) int { + switch m := img.(type) { + case *image.YCbCr: + return int(m.Y[m.YOffset(x, y)]) + case *image.Gray: + return int(m.Pix[m.PixOffset(x, y)]) + } + return int(color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y) +} + +// Box finds the document's box in the image, and whether there is one: false when no document is found or +// when it fills (almost) the platen. +func Box(img image.Image) (image.Rectangle, bool) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w < 2*margin || h < 2*margin { + return b, false + } + side := lidBlock + if side > w/2 { + side = w / 2 + } + if side > h/2 { + side = h / 2 + } + sum := 0 + for y := b.Max.Y - side; y < b.Max.Y; y++ { + for x := b.Max.X - side; x < b.Max.X; x++ { + sum += grayAt(img, x, y) + } + } + lid := int(float64(sum)/float64(side*side) + 0.5) + limit := lid - darker + + cols := make([]int, w) + rows := make([]int, h) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if grayAt(img, x, y) < limit { + cols[x-b.Min.X]++ + rows[y-b.Min.Y]++ + } + } + } + x0, x1 := span(cols, int(rowShare*float64(h))) + y0, y1 := span(rows, int(rowShare*float64(w))) + if x0 < 0 || y0 < 0 { + return b, false + } + box := image.Rect(max(0, x0-margin), max(0, y0-margin), min(w, x1+1+margin), min(h, y1+1+margin)).Add(b.Min) + if float64(box.Dx()*box.Dy()) >= wholeShare*float64(w*h) { + return b, false + } + return box, true +} + +// span is the first and last index whose count is over the floor, or -1 when none is. +func span(counts []int, floor int) (int, int) { + first, last := -1, -1 + for i, c := range counts { + if c > floor { + if first < 0 { + first = i + } + last = i + } + } + return first, last +} + +// Cropped is what cropping a scan gave. +type Cropped struct { + // JPEG is the image to file: the scanner's own bytes when kept whole, a re-encoded crop otherwise. + JPEG []byte + Width, Height int + Cropped bool +} + +// Crop cuts the document out of a scanned JPEG when crop is set and a document is found; otherwise the whole +// image is kept, its bytes untouched, and Cropped is false. +func Crop(scan []byte, crop bool) (Cropped, error) { + img, err := jpeg.Decode(bytes.NewReader(scan)) + if err != nil { + return Cropped{}, fmt.Errorf("the scanned image cannot be read: %w", err) + } + b := img.Bounds() + whole := Cropped{JPEG: scan, Width: b.Dx(), Height: b.Dy()} + if !crop { + return whole, nil + } + box, found := Box(img) + if !found { + return whole, nil + } + sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }) + if !ok { + return whole, nil + } + var out bytes.Buffer + if err := jpeg.Encode(&out, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil { + return Cropped{}, fmt.Errorf("the cropped image cannot be encoded: %w", err) + } + return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil +} diff --git a/modules/brother/cmd/brother/crop_test.go b/modules/brother/cmd/brother/crop_test.go new file mode 100644 index 00000000..b61a263c --- /dev/null +++ b/modules/brother/cmd/brother/crop_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/jpeg" + "math/rand" + "testing" +) + +// lidWhite is a w x h gray image of the lid's white, with a few specks of dust in one patch. +func lidWhite(w, h int, seed int64) *image.Gray { + g := image.NewGray(image.Rect(0, 0, w, h)) + draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src) + r := rand.New(rand.NewSource(seed)) + for i := 0; i < 40; i++ { + g.SetGray(100+r.Intn(w/4), 100+r.Intn(h/4), color.Gray{120}) // all in one patch, so counting them would make a box + } + return g +} + +func encode(t *testing.T, img image.Image) []byte { + t.Helper() + var b bytes.Buffer + if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +// 1440/AC3: the captured receipt, top-left of a 1696x2203 scan at 200 dpi. +func TestCropFindsTheCapturedReceipt(t *testing.T) { + c, err := Crop(fixture(t, "scan.jpg"), true) + if err != nil { + t.Fatal(err) + } + if !c.Cropped { + t.Fatal("cropped = false for a scan with a receipt on it") + } + img, err := jpeg.Decode(bytes.NewReader(fixture(t, "scan.jpg"))) + if err != nil { + t.Fatal(err) + } + box, found := Box(img) + if !found { + t.Fatal("no box found") + } + if box.Min.X > 10 || box.Min.Y > 10 { + t.Errorf("box starts at %v, want within 10 px of (0,0)", box.Min) + } + if box.Max.X < 630 || box.Max.X > 660 { + t.Errorf("right edge %d, want 630..660", box.Max.X) + } + if box.Max.Y < 1730 || box.Max.Y > 1810 { + t.Errorf("bottom %d, want 1730..1810", box.Max.Y) + } + if c.Width != box.Dx() || c.Height != box.Dy() { + t.Errorf("cropped image is %dx%d, the box %dx%d", c.Width, c.Height, box.Dx(), box.Dy()) + } + got, err := jpeg.DecodeConfig(bytes.NewReader(c.JPEG)) + if err != nil || got.Width != box.Dx() || got.Height != box.Dy() { + t.Errorf("the cropped JPEG is %+v (%v), want %dx%d", got, err, box.Dx(), box.Dy()) + } + // About 80-84 mm wide at 200 dpi. + if mm := float64(c.Width) / 200 * 25.4; mm < 80 || mm > 84 { + t.Errorf("%.1f mm wide, want 80-84", mm) + } +} + +// 1440/AC4: a plain lid with specks has no document. +func TestCropKeepsAPlainLidWhole(t *testing.T) { + scan := encode(t, lidWhite(800, 1100, 1)) + c, err := Crop(scan, true) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 800 || c.Height != 1100 || !bytes.Equal(c.JPEG, scan) { + t.Fatalf("a plain lid was cropped: %+v", c) + } +} + +// 1440/AC4: a document that fills the platen is no crop either. +func TestCropKeepsAFullPlatenDocumentWhole(t *testing.T) { + g := lidWhite(800, 1100, 2) + draw.Draw(g, image.Rect(0, 0, 790, 1090), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) + scan := encode(t, g) + c, err := Crop(scan, true) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 800 || c.Height != 1100 { + t.Fatalf("a document filling the platen was cropped: %+v", c) + } +} + +// 1440/AC4: crop=false keeps the captured scan whole, though it has a document. +func TestCropOffKeepsTheWholeImage(t *testing.T) { + scan := fixture(t, "scan.jpg") + c, err := Crop(scan, false) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 1696 || c.Height != 2203 || !bytes.Equal(c.JPEG, scan) { + t.Fatalf("crop=false cropped: %+v", c) + } +} + +// 1440/AC3: a document away from the origin is found where it is, not assumed in the corner. +func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) { + g := lidWhite(800, 1100, 3) + draw.Draw(g, image.Rect(200, 300, 500, 700), &image.Uniform{color.Gray{240}}, image.Point{}, draw.Src) + img, err := jpeg.Decode(bytes.NewReader(encode(t, g))) + if err != nil { + t.Fatal(err) + } + box, found := Box(img) + if !found { + t.Fatal("no box") + } + if box.Min.X < 190 || box.Min.X > 205 || box.Max.X < 495 || box.Max.X > 510 || box.Min.Y < 290 || box.Min.Y > 305 || box.Max.Y < 695 || box.Max.Y > 710 { + t.Fatalf("box = %v, want about (200,300)-(500,700)", box) + } +} diff --git a/modules/brother/cmd/brother/device_boundary_test.go b/modules/brother/cmd/brother/device_boundary_test.go new file mode 100644 index 00000000..5c355612 --- /dev/null +++ b/modules/brother/cmd/brother/device_boundary_test.go @@ -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) + } + } +} diff --git a/modules/brother/cmd/brother/device_integration_test.go b/modules/brother/cmd/brother/device_integration_test.go new file mode 100644 index 00000000..e54cd5fa --- /dev/null +++ b/modules/brother/cmd/brother/device_integration_test.go @@ -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) + } +} diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go new file mode 100644 index 00000000..b080fdfc --- /dev/null +++ b/modules/brother/cmd/brother/deviceproc.go @@ -0,0 +1,498 @@ +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" + "github.com/nats-io/nats.go/jetstream" + + "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 + // Capture scans; nil is the device's own (a test puts a stand-in). + Capture func(ctx context.Context) (Capture, error) + // answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger). + answerPart int + 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...) + } +} + +// tooLargeCode is the bus's error code for a message over the maximum allowed (the state refuses a value so). +const tooLargeCode = 10054 + +// 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; an answer the state refuses as too large is +// replaced by a small error answer and the message terminated; an answer that could not be written for another +// reason (the store away, a timeout) 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) + var api *jetstream.APIError + if !errors.As(err, &api) || api.ErrorCode != tooLargeCode { + // The store is away, timed out or cut: the answer may be written next time (a scan is settled already). + if settled { + return actionSettled, 0 + } + return actionRetry, 5 * time.Second + } + // The ask ends here: a refused value would be refused again. + // Say the failure as a small error answer, so the tool's wait ends with the reason (issue 1535). + msg := fmt.Sprintf("the answer to this ask could not be written in the state %s: %v", AnswersState, err) + if _, err2 := w.Answers.Create(ask.ID, map[string]any{"error": msg}); err2 != nil && !errors.Is(err2, stdio.ErrChanged) { + w.logf("[brother] device: the failure of %s could not be written either: %v", ask.ID, err2) + } + if settled { + return actionSettled, 0 + } + return actionTerm, 0 + } + 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, ask.Links, ask.Hidden) + 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} + capture := svc.Capture + if w.Capture != nil { + capture = w.Capture + } + c, err := 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": answerPartCount(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 + } + each := w.answerPart + if each <= 0 { + each = AnswerPartSize + } + part, n, err := cutPartOf(data, ask.Part, each) + 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/.. 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, links, hidden bool) (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) + } + var nf *notFoundError + if errors.As(err, &nf) { + return nil, fmt.Errorf("the printer has no page %s", page) + } + return nil, fmt.Errorf("page %s cannot be read: %w", page, err) + } + logout := a.Logout(lctx, body, at) + p := ParsePage(body) + out := map[string]any{ + "page": page, + "title": p.Title, + "forms": nonNilForms(withoutHidden(p.Forms, hidden)), + "logout": logout, + } + if links { + out["links"] = nonNil(p.Links) + } + return out, nil +} + +// withoutHidden leaves the hidden fields (the CSRF token and the like, noise to a reader) out of the forms +// unless they are asked for. +func withoutHidden(forms []Form, keep bool) []Form { + if keep { + return forms + } + out := make([]Form, 0, len(forms)) + for _, f := range forms { + var fields []Field + for _, fl := range f.Fields { + if fl.Type != "hidden" { + fields = append(fields, fl) + } + } + if len(fields) == 0 && len(f.Fields) > 0 { + continue // a form of nothing but hidden fields (the logout form) has nothing to read + } + f.Fields = fields + out = append(out, f) + } + return out +} + +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) + } + }) +} diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go new file mode 100644 index 00000000..9e96c1ef --- /dev/null +++ b/modules/brother/cmd/brother/documents_test.go @@ -0,0 +1,541 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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*AnswerPartSize + 1234) + n := answerPartCount(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) > AnswerPartSize { + t.Errorf("part %d is %d bytes, over %d", i, len(p), AnswerPartSize) + } + 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(AnswerPartSize + 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*AnswerPartSize + 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/.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, _, _ := cutPartOf(doc, 1, PartSize) + p2, _, _ := cutPartOf(doc, 2, PartSize) + 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, _, _ := cutPartOf(p, 1, PartSize) + 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, _, _ := cutPartOf(doc, 1, PartSize) + p2, _, _ := cutPartOf(doc, 2, PartSize) + 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) + } +} + +// 1535: a scan larger than the state's value limit (256 KiB) is filed whole, its SHA-256 equal, and the answer to +// the scan itself is small: it carries the event's body and never the document. Every part answer fits the state. +func TestAScanLargerThanTheStatesValueLimitIsFiledWholeAndItsAnswerIsSmall(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + r, folder := scanRig(t, f, Settings{Crop: false}) + // The real capture is small; the worker keeps a stand-in of 3 parts' size under the key the scan names. + big := pdfOf(222388 + 2*AnswerPartSize) + if len(big) <= StateMaxValue { + t.Fatal("the stand-in is not larger than the state's value limit") + } + r.worker.Capture = func(ctx context.Context) (Capture, error) { + return Capture{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}, nil + } + body, err := r.scan(t) + if err != nil { + t.Fatal(err) + } + path, _ := body["path"].(string) + got, err := os.ReadFile(path) + if err != nil || sha256Hex(got) != sha256Hex(big) || filepath.Dir(path) != folder { + t.Fatalf("filed %s: %v, SHA-256 equal %v", path, err, err == nil && sha256Hex(got) == sha256Hex(big)) + } + for id, raw := range r.answers.m { + if len(raw) > StateMaxValue { + t.Errorf("answer %s is %d bytes, over the state's %d", id, len(raw), StateMaxValue) + } + } + for _, a := range r.bus.published { + if a.Op == OpScan { + if raw := r.answers.get(a.ID); len(raw) > 4<<10 || bytes.Contains(raw, []byte(`"data"`)) { + t.Errorf("the scan's answer is %d bytes and may carry the document: %s", len(raw), raw) + } + } + } +} + +// 1535: an answer the state refuses ends the ask: a small error answer says why, and the queued message is +// terminated, never redelivered into the same failure; a scan's message, acknowledged early, stays settled. +func TestARefusedAnswerEndsTheAskWithAnErrorAndNoRedelivery(t *testing.T) { + r := newRig(t, `{}`, "pw") + big := pdfOf(300 << 10) + key := sha256Hex(big) + ".pdf" + r.objects.Put(t.Context(), key, "application/pdf", big) + // A part answer of 700 KiB, as the old cut made it, is over the state's limit; the worker must not loop on it. + r.worker.answerPart = PartSize + ask, _ := json.Marshal(Ask{ID: "big1", Op: OpDocument, Object: key, Part: 1, At: fixedNow}) + act, _ := r.worker.Handle(ask, nil) + if act != actionTerm { + t.Errorf("action = %v, want term", act) + } + raw := r.answers.get("big1") + if raw == nil || !bytes.Contains(raw, []byte(`"error"`)) || len(raw) > 4<<10 { + t.Errorf("no small error answer: %s", raw) + } +} + +// 1535: only a refused value (error code 10054, message too large) ends the ask; a store that is away, a timeout +// or a cut connection is redelivered, a document fetch being safe to repeat. +func TestOnlyATooLargeAnswerEndsTheAskATransientFailureIsRetried(t *testing.T) { + r := newRig(t, `{}`, "pw") + big := pdfOf(300 << 10) + key := sha256Hex(big) + ".pdf" + r.objects.Put(t.Context(), key, "application/pdf", big) + ask, _ := json.Marshal(Ask{ID: "t1", Op: OpDocument, Object: key, Part: 1, At: fixedNow}) + r.answers.fail = fmt.Errorf("nats: timeout") + if act, d := r.worker.Handle(ask, nil); act != actionRetry || d <= 0 { + t.Errorf("a timeout: action %v delay %v, want retry", act, d) + } + r.answers.fail = nil + r.worker.answerPart = PartSize + ask2, _ := json.Marshal(Ask{ID: "t2", Op: OpDocument, Object: key, Part: 1, At: fixedNow}) + if act, _ := r.worker.Handle(ask2, nil); act != actionTerm { + t.Errorf("a too large answer: action %v, want term", act) + } +} diff --git a/modules/brother/cmd/brother/escl.go b/modules/brother/cmd/brother/escl.go new file mode 100644 index 00000000..1f79c348 --- /dev/null +++ b/modules/brother/cmd/brother/escl.go @@ -0,0 +1,332 @@ +package main + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// eSCL (the AirScan protocol) over HTTP: POST /ScanJobs answers 201 and the job's Location, GET +// /NextDocument answers the image, and a second NextDocument answers 404 once the job is done (that is what closes it; DELETE of the job does too). + +// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes. +const maxImage = 256 << 20 + +// Client speaks eSCL to one scanner. +type Client struct { + Base string + HTTP *http.Client + RetryWait time.Duration // the wait before the one retry of a busy scanner (default 4 s) + Logf func(format string, a ...any) +} + +// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan. +func NewClient(base string) *Client { + return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}} +} + +// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in. +type Capabilities struct { + MaxWidth, MaxHeight int +} + +type capabilitiesXML struct { + MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"` + MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"` +} + +// Status is what /ScannerStatus says. +type Status struct { + State string `xml:"State" json:"state"` + AdfState string `xml:"AdfState" json:"adf-state"` +} + +func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + return c.HTTP.Do(req) +} + +func readBody(resp *http.Response, limit int64) ([]byte, error) { + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, limit)) +} + +// Capabilities asks the scanner how large its glass is. +func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) { + resp, err := c.get(ctx, c.Base+"/ScannerCapabilities") + if err != nil { + return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err) + } + body, err := readBody(resp, 1<<20) + if err != nil { + return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status) + } + var x capabilitiesXML + if err := xml.Unmarshal(body, &x); err != nil { + return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err) + } + if x.MaxWidth <= 0 || x.MaxHeight <= 0 { + return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size") + } + return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil +} + +// Status asks what the scanner is doing. +func (c *Client) Status(ctx context.Context) (Status, error) { + resp, err := c.get(ctx, c.Base+"/ScannerStatus") + if err != nil { + return Status{}, fmt.Errorf("the scanner does not answer: %w", err) + } + body, err := readBody(resp, 1<<20) + if err != nil { + return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status) + } + var s Status + if err := xml.Unmarshal(body, &s); err != nil { + return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err) + } + return s, nil +} + +// colorModes maps the config's words to eSCL's. +var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"} + +// ScanSettings is the request body: the Platen, the whole glass, the colour mode and resolution, as a JPEG. +func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) { + escl, ok := colorModes[colorMode] + if !ok { + return "", fmt.Errorf("color-mode %q is not color or gray", colorMode) + } + return fmt.Sprintf(` + +2.63 +escl:ThreeHundredthsOfInches00%d%d +Platen +%s +%d%d +image/jpeg + +`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil +} + +// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the +// real one is the image's own density (Density), for the real scanner answered 300 with 200. +// +// A job stays open on the scanner (pwg:State Processing, and 503 to the next ScanJobs) until NextDocument has +// answered 404 or the job is DELETEd, so Scan always ends the job it made: after the page it asks NextDocument +// again, and on any failure after the job was created it deletes the job. +func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) { + caps, err := c.Capabilities(ctx) + if err != nil { + return nil, err + } + settings, err := ScanSettings(caps, resolution, colorMode) + if err != nil { + return nil, err + } + job, err := c.createJob(ctx, settings) + if err != nil { + return nil, err + } + doc := job + "/NextDocument" + image, err := c.page(ctx, doc) + if err != nil { + c.deleteJob(job) + return nil, err + } + closed, extra := c.closeJob(ctx, doc) + if !closed { + c.deleteJob(job) + } + if extra > 0 { + return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra) + } + return image, nil +} + +// leftOpen is the job URL, per scanner, that brother could neither finish nor delete. +var leftOpen sync.Map + +func (c *Client) logf(format string, a ...any) { + if c.Logf != nil { + c.Logf(format, a...) + return + } + fmt.Fprintf(os.Stderr, format+"\n", a...) +} + +func (c *Client) retryWait() time.Duration { + if c.RetryWait > 0 { + return c.RetryWait + } + return 4 * time.Second +} + +// createJob posts the settings and returns the job's URL. A 503 is a scanner busy with another job; when the job +// brother itself left open on this scanner is now closed, the post goes once more, never more than once. +func (c *Client) createJob(ctx context.Context, settings string) (string, error) { + resp, reply, err := c.postJob(ctx, settings) + if err != nil { + return "", err + } + if resp.StatusCode == http.StatusServiceUnavailable { + if left, ok := leftOpen.Load(c.Base); ok { + select { + case <-time.After(c.retryWait()): + case <-ctx.Done(): + return "", ctx.Err() + } + if c.deleteJob(left.(string)) { + if resp, reply, err = c.postJob(ctx, settings); err != nil { + return "", err + } + } + } + if resp.StatusCode == http.StatusServiceUnavailable { + return "", fmt.Errorf("the scanner is busy with another job: %s %s", resp.Status, strings.TrimSpace(string(reply))) + } + } + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply))) + } + loc := resp.Header.Get("Location") + if loc == "" { + return "", fmt.Errorf("the scanner took the scan job but named no Location") + } + base, _ := url.Parse(c.Base + "/") + ref, err := url.Parse(loc) + if err != nil { + return "", fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err) + } + return strings.TrimRight(base.ResolveReference(ref).String(), "/"), nil +} + +func (c *Client) postJob(ctx context.Context, settings string) (*http.Response, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", strings.NewReader(settings)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "text/xml") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("the scanner does not answer: %w", err) + } + reply, _ := readBody(resp, 1<<20) + return resp, reply, nil +} + +// page fetches the job's one page. +func (c *Client) page(ctx context.Context, doc string) ([]byte, error) { + got, err := c.get(ctx, doc) + if err != nil { + return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err) + } + image, err := readBody(got, maxImage) + if err != nil { + return nil, fmt.Errorf("the scanned page cannot be read: %w", err) + } + if got.StatusCode == http.StatusGone { + return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status) + } + if got.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the scanner gave no page: %s", got.Status) + } + if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") { + return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct) + } + return image, nil +} + +// maxClosing bounds the NextDocument requests that ask for the end of a job. +const maxClosing = 1 + +// closeJob asks NextDocument until the scanner answers 404, which ends the job; true when it did (or the job is +// gone already), and how many further pages it sent instead; any is an error for the caller, for a Platen job holds +// one page. It stops at the first further page, and the job is then deleted. +func (c *Client) closeJob(ctx context.Context, doc string) (closed bool, extra int) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + defer cancel() + for i := 0; i < maxClosing; i++ { + resp, err := c.get(ctx, doc) + if err != nil { + c.logf("brother: closing the scan job: %v", err) + return false, 0 + } + readBody(resp, maxImage) + switch resp.StatusCode { + case http.StatusNotFound, http.StatusGone: + return true, 0 + case http.StatusOK: + c.logf("brother: the scanner sent a further page after the glass's one") + return false, 1 + } + c.logf("brother: closing the scan job: NextDocument answered %s", resp.Status) + return false, 0 + } + return false, 0 +} + +// deleteJob deletes the job, best effort; true when the job is gone. A job that cannot be deleted is remembered, so +// a 503 on the next scan can be told from another client's job. +func (c *Client) deleteJob(job string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, job, nil) + if err == nil { + var resp *http.Response + if resp, err = c.HTTP.Do(req); err == nil { + readBody(resp, 1<<20) + if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + leftOpen.Delete(c.Base) + return true + } + err = fmt.Errorf("answered %s", resp.Status) + } + } + c.logf("brother: deleting the scan job %s: %v", job, err) + leftOpen.Store(c.Base, job) + return false +} + +// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm). +func Density(jpegBytes []byte) (int, error) { + b := jpegBytes + if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 { + return 0, fmt.Errorf("the image is not a JPEG") + } + for i := 2; i+4 <= len(b) && b[i] == 0xFF; { + marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3]) + if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" { + units := b[i+11] + x := int(b[i+12])<<8 | int(b[i+13]) + switch { + case units == 1 && x > 0: + return x, nil + case units == 2 && x > 0: + return int(float64(x)*2.54 + 0.5), nil + } + return 0, fmt.Errorf("the image's JFIF header gives no resolution") + } + if marker == 0xDA || n < 2 { + break + } + i += 2 + n + } + return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown") +} diff --git a/modules/brother/cmd/brother/escl_test.go b/modules/brother/cmd/brother/escl_test.go new file mode 100644 index 00000000..6418fb9c --- /dev/null +++ b/modules/brother/cmd/brother/escl_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +func squeeze(s string) string { return strings.ReplaceAll(s, "\n", "") } + +// 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server. +func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + image, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err != nil { + t.Fatal(err) + } + if f.posts != 1 { + t.Fatalf("%d ScanJobs posts, want 1", f.posts) + } + // The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507 + // 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg. + if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want { + t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want) + } + // The page, then the second request that the scanner answers 404, which closes the job. + if f.documents != 2 || f.open || f.deletes != 0 { + t.Fatalf("%d NextDocument requests, job open %v, %d DELETEs, want 2, closed, 0", f.documents, f.open, f.deletes) + } + if want := fixture(t, "scan.jpg"); string(image) != string(want) { + t.Fatalf("the returned image is %d bytes, want the scanner's %d", len(image), len(want)) + } +} + +// 1440/AC1: the colour mode and resolution asked are the configured ones. +func TestScanAsksForTheConfiguredResolutionAndColourMode(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + if _, err := NewClient(f.base()).Scan(context.Background(), 600, "gray"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"Grayscale8", "600", "600"} { + if !strings.Contains(f.postBody, want) { + t.Errorf("the job lacks %s:\n%s", want, f.postBody) + } + } +} + +// 1440/AC1: a job the scanner refuses is an error that says so, and no page is fetched. +func TestScanSaysWhyAScannerRefusal(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.refuse = 503 + _, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err == nil || !strings.Contains(err.Error(), "503") { + t.Fatalf("error = %v, want the refusal's 503", err) + } + if f.documents != 0 { + t.Fatalf("%d documents fetched after a refusal", f.documents) + } +} + +// 1440/AC2: the image's own density, never the request's. +func TestDensityIsTheImagesOwn(t *testing.T) { + dpi, err := Density(fixture(t, "scan.jpg")) + if err != nil { + t.Fatal(err) + } + if dpi != 200 { + t.Fatalf("density = %d dpi, want the captured scan's 200 (the request said 300)", dpi) + } +} + +func TestDensityOfAnImageWithNoneIsAnError(t *testing.T) { + if _, err := Density([]byte{0xFF, 0xD8, 0xFF, 0xDA, 0, 2}); err == nil { + t.Fatal("an image with no JFIF header has a resolution") + } +} + +// 1440/AC8: the captured status replies. +func TestStatusReadsStateAndAdfState(t *testing.T) { + f := newFakeScanner(t, nil) + s, err := NewClient(f.base()).Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if s.State != "Idle" || s.AdfState != "ScannerAdfEmpty" { + t.Fatalf("status = %+v, want Idle / ScannerAdfEmpty", s) + } +} + +// 1440/AC7: which value is unset is said by name. +func TestMissingNamesWhatIsUnset(t *testing.T) { + for _, c := range []struct { + s Settings + want string + }{ + {Settings{Folder: "/x"}, "scanner"}, + {Settings{Scanner: "http://192.0.2.10/eSCL"}, "scan-folder"}, + } { + err := c.s.Missing() + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Missing(%+v) = %v, want it to name %s", c.s, err, c.want) + } + } + if err := (Settings{Scanner: "a", Folder: "/x"}).Missing(); err != nil { + t.Errorf("both set: %v", err) + } +} + +// 1531: two scans in a row both succeed, for the scanner answers 503 to a new job while one is open. +func TestTwoScansInARowBothSucceed(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + c := NewClient(f.base()) + for i := 1; i <= 2; i++ { + if _, err := c.Scan(context.Background(), 300, "color"); err != nil { + t.Fatalf("scan %d: %v", i, err) + } + } + if f.posts != 2 || f.open { + t.Fatalf("%d posts, job open %v, want 2, closed", f.posts, f.open) + } +} + +// 1531: a page that cannot be had (the answer is lost) leaves no job open: the job is DELETEd. +func TestAJobWhoseNextDocumentFailsIsDeleted(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.dropConn = true + if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err == nil { + t.Fatal("a lost answer is a scan") + } + if f.deletes != 1 || f.open { + t.Fatalf("%d DELETEs, job open %v, want 1, closed", f.deletes, f.open) + } + f.mu.Lock() + f.dropConn = false + f.mu.Unlock() + if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err != nil { + t.Fatalf("the scan after it: %v", err) + } +} + +// 1531: a job with no page (404) is deleted too. +func TestAJobThatGivesNoPageIsDeleted(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.noPage = true + if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err == nil { + t.Fatal("no page is a scan") + } + if f.open { + t.Fatal("the job stays open") + } +} + +// 1531: a 410 on NextDocument is said as a dropped job, and the job is deleted. +func TestAGoneJobIsSaidAsDroppedAndDeleted(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.gone = true + _, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err == nil || !strings.Contains(err.Error(), "the scanner dropped the job") { + t.Fatalf("error = %v, want the scanner dropped the job", err) + } + if f.deletes != 1 { + t.Fatalf("%d DELETEs, want 1", f.deletes) + } +} + +// 1531: a 503 while another job is open is said as busy, with no loop and no retry when brother left no job. +func TestABusyScannerIsSaidAndNotRetriedWhenBrotherLeftNoJob(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.open = true // another client's job + c := NewClient(f.base()) + c.RetryWait = time.Millisecond + _, err := c.Scan(context.Background(), 300, "color") + if err == nil || !strings.Contains(err.Error(), "the scanner is busy with another job") { + t.Fatalf("error = %v, want busy", err) + } + if f.posts != 1 { + t.Fatalf("%d posts, want 1", f.posts) + } +} + +// 1531: a job brother left open (it could not close it) is closed after a wait, and the refused POST goes once more. +func TestABusyScannerWithABrotherJobLeftIsRetriedOnceAfterItCloses(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.stuck = true + c := NewClient(f.base()) + c.RetryWait = time.Millisecond + if _, err := c.Scan(context.Background(), 300, "color"); err == nil { + t.Fatal("a stuck job is a scan") + } + f.mu.Lock() + if !f.open { + t.Fatal("the fake should have kept the job open") + } + f.stuck = false + f.mu.Unlock() + c2 := NewClient(f.base()) + c2.RetryWait = time.Millisecond + if _, err := c2.Scan(context.Background(), 300, "color"); err != nil { + t.Fatalf("scan after the left job closes: %v", err) + } + if f.posts != 3 { + t.Fatalf("%d posts, want 3 (stuck job, refused, retried)", f.posts) + } +} + +// 1531: a second page for a Platen job is an error, never a silently incomplete scan, and the job is closed. +func TestMorePagesThanTheGlassHoldsIsAnErrorAndTheJobIsClosed(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.pages = 2 + image, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err == nil || !strings.Contains(err.Error(), "the scanner gave more pages than the glass holds") { + t.Fatalf("image %d bytes, error = %v, want more pages than the glass holds", len(image), err) + } + if f.open || f.deletes != 1 { + t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes) + } +} diff --git a/modules/brother/cmd/brother/file.go b/modules/brother/cmd/brother/file.go new file mode 100644 index 00000000..44de023a --- /dev/null +++ b/modules/brother/cmd/brother/file.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// FileMode is the mode a created scan folder gets and ScanMode the mode of a filed scan. +const ( + FolderMode os.FileMode = 0o755 + ScanMode os.FileMode = 0o644 +) + +// File writes the PDF into the folder as scan-YYYY-MM-DD-HHMMSS.pdf (local time): whole beside its place as +// .scan-*.tmp and then renamed, so nothing ever sees half a scan. A name taken already gets -2, -3, and a file +// that is there is never overwritten. The folder is created when it is not there. Nothing in it that this +// writes is touched but what it makes (1440/AC5, AC11). +func File(folder string, pdf []byte, at time.Time) (string, error) { + if !filepath.IsAbs(folder) { + return "", fmt.Errorf("scan-folder %q is not an absolute path", folder) + } + if _, err := os.Stat(folder); os.IsNotExist(err) { + if err := os.MkdirAll(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + // The umask may have narrowed it; a folder this made is 0755 whatever the umask. + if err := os.Chmod(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + } + tmp, err := os.CreateTemp(folder, ".scan-*.tmp") + if err != nil { + return "", fmt.Errorf("the scan cannot be written in %s: %w", folder, err) + } + done := false + defer func() { + if !done { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + if _, err := tmp.Write(pdf); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Sync(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := os.Chmod(tmp.Name(), ScanMode); err != nil { + return "", err + } + stem := "scan-" + at.Local().Format("2006-01-02-150405") + for n := 1; n < 10000; n++ { + name := stem + ".pdf" + if n > 1 { + name = fmt.Sprintf("%s-%d.pdf", stem, n) + } + final := filepath.Join(folder, name) + // The name is taken by creating it exclusively; the finished file is then renamed over that empty + // one, so a scan never replaces a file that was there. + f, err := os.OpenFile(final, os.O_CREATE|os.O_EXCL|os.O_WRONLY, ScanMode) + if os.IsExist(err) { + continue + } + if err != nil { + return "", fmt.Errorf("the scan cannot be named in %s: %w", folder, err) + } + f.Close() + if err := os.Rename(tmp.Name(), final); err != nil { + os.Remove(final) + return "", fmt.Errorf("the scan cannot be put in place: %w", err) + } + done = true + return final, nil + } + return "", fmt.Errorf("no free scan name for %s in %s", stem, folder) +} + +// Entry is one filed scan, as scan_list says it. +type Entry struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Modified time.Time `json:"modified"` +} + +// List is the newest PDFs named scan-*.pdf in the folder, newest first, at most limit. A folder that is not +// there yet is an empty list. +func List(folder string, limit int) ([]Entry, error) { + entries, err := os.ReadDir(folder) + if os.IsNotExist(err) { + return []Entry{}, nil + } + if err != nil { + return nil, fmt.Errorf("the scan folder cannot be read: %w", err) + } + out := []Entry{} + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasPrefix(n, "scan-") || !strings.HasSuffix(n, ".pdf") { + continue + } + info, err := e.Info() + if err != nil || !info.Mode().IsRegular() { + continue + } + out = append(out, Entry{Path: filepath.Join(folder, n), Bytes: info.Size(), Modified: info.ModTime()}) + } + sort.SliceStable(out, func(i, j int) bool { + if !out[i].Modified.Equal(out[j].Modified) { + return out[i].Modified.After(out[j].Modified) + } + return out[i].Path > out[j].Path + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/modules/brother/cmd/brother/file_test.go b/modules/brother/cmd/brother/file_test.go new file mode 100644 index 00000000..c92a8ca2 --- /dev/null +++ b/modules/brother/cmd/brother/file_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +var scanName = regexp.MustCompile(`^scan-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.pdf$`) + +// 1440/AC5: a valid one-page PDF holding the JPEG as DCTDecode, at the image's real size. +func TestPDFHoldsTheJPEGAtItsRealSize(t *testing.T) { + jpg := fixture(t, "scan.jpg") + pdf, err := PDF(jpg, 200) + if err != nil { + t.Fatal(err) + } + s := string(pdf) + if !strings.HasPrefix(s, "%PDF-1.4") || !strings.HasSuffix(s, "%%EOF\n") { + t.Fatalf("not a PDF: starts %q ends %q", s[:8], s[len(s)-6:]) + } + if !bytes.Contains(pdf, jpg) { + t.Error("the JPEG is not embedded as it is") + } + for _, want := range []string{"/Filter /DCTDecode", "/Width 1696", "/Height 2203", "/ColorSpace /DeviceRGB", "/Count 1"} { + if !strings.Contains(s, want) { + t.Errorf("the PDF lacks %s", want) + } + } + // 1696 x 2203 px at 200 dpi is 610.56 x 793.08 pt, never the 300 dpi that was asked. + if !strings.Contains(s, "/MediaBox [0 0 610.56 793.08]") { + t.Errorf("page size wrong:\n%s", regexp.MustCompile(`/MediaBox[^\]]*\]`).FindString(s)) + } + if n := strings.Count(s, "/Type /Page "); n != 1 { + t.Errorf("%d pages", n) + } + // Every xref entry points at its object. + at := strings.LastIndex(s, "startxref\n") + xref, _ := strconv.Atoi(strings.Fields(s[at+len("startxref\n"):])[0]) + lines := strings.Split(s[xref:], "\n") + for i := 3; i <= 7 && i < len(lines); i++ { + off, err := strconv.Atoi(lines[i][:10]) + if err != nil || !strings.HasPrefix(s[off:], fmt.Sprintf("%d 0 obj", i-2)) { + t.Errorf("xref entry %d (%q) does not point at object %d", i-2, lines[i], i-2) + } + } +} + +// 1440/AC2: the page size follows the density it is given. +func TestPDFPageSizeFollowsTheDensity(t *testing.T) { + jpg := fixture(t, "scan.jpg") + a, _ := PDF(jpg, 200) + b, _ := PDF(jpg, 300) + if bytes.Equal(a, b) { + t.Fatal("200 and 300 dpi give the same page") + } + if !strings.Contains(string(b), "/MediaBox [0 0 407.04 528.72]") { + t.Errorf("300 dpi page wrong") + } + if _, err := PDF(jpg, 0); err == nil { + t.Error("0 dpi accepted") + } +} + +// 1440/AC5: exactly one file, named by local time, no temporary left, its bytes the PDF. +func TestFileWritesOneNamedFileAndLeavesNoTemporary(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + path, err := File(folder, []byte("%PDF-x"), at) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(folder, "scan-2026-10-20-190509.pdf"); path != want { + t.Fatalf("path = %s, want %s", path, want) + } + entries, _ := os.ReadDir(folder) + if len(entries) != 1 || !scanName.MatchString(entries[0].Name()) { + t.Fatalf("folder holds %v, want the one scan", entries) + } + if got, _ := os.ReadFile(path); string(got) != "%PDF-x" { + t.Fatalf("file holds %q", got) + } +} + +// 1440/AC5: a name taken gets -2, -3; what was there is never overwritten. +func TestFileNeverOverwritesAndSuffixes(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + first, _ := File(folder, []byte("one"), at) + second, err := File(folder, []byte("two"), at) + if err != nil { + t.Fatal(err) + } + third, _ := File(folder, []byte("three"), at) + for p, want := range map[string]string{ + first: "one", second: "two", third: "three", + } { + if got, _ := os.ReadFile(p); string(got) != want { + t.Errorf("%s holds %q, want %q", p, got, want) + } + } + if filepath.Base(second) != "scan-2026-10-20-190509-2.pdf" || filepath.Base(third) != "scan-2026-10-20-190509-3.pdf" { + t.Errorf("names %s and %s", filepath.Base(second), filepath.Base(third)) + } +} + +// 1440/AC11: a folder that is not there is made, 0755, and the scan is filed in it. +func TestFileMakesAMissingFolder(t *testing.T) { + folder := filepath.Join(t.TempDir(), "a", "scans") + path, err := File(folder, []byte("x"), time.Now()) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(folder) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("folder: %v, mode %v, want 0755", err, info) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +// 1440/AC11: a file in the folder that the module did not write is left as it was by every tool. +func TestFileLeavesForeignFilesAlone(t *testing.T) { + folder := t.TempDir() + old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + foreign := map[string]string{"notes.txt": "mine", ".scan-theirs.tmp": "half", "scan-2020-01-01-000000.pdf": "someone else's"} + for n, c := range foreign { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte(c), 0o600) + os.Chtimes(p, old, old) + } + if _, err := File(folder, []byte("x"), time.Now()); err != nil { + t.Fatal(err) + } + if _, err := List(folder, 10); err != nil { + t.Fatal(err) + } + for n, c := range foreign { + p := filepath.Join(folder, n) + got, err := os.ReadFile(p) + info, _ := os.Stat(p) + if err != nil || string(got) != c || !info.ModTime().Equal(old) || info.Mode().Perm() != 0o600 { + t.Errorf("%s changed: %q %v", n, got, err) + } + } +} + +// 1440/AC7: a relative folder writes nothing. +func TestFileRefusesARelativeFolder(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if _, err := File("scans", []byte("x"), time.Now()); err == nil { + t.Fatal("a relative folder was accepted") + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("wrote %v", entries) + } +} + +// 1440/AC8: scan_list: newest first, only scan PDFs, limited. +func TestListIsNewestFirstAndLimited(t *testing.T) { + folder := t.TempDir() + base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + for i, n := range []string{"scan-a.pdf", "scan-b.pdf", "scan-c.pdf", "other.pdf", "scan-d.txt"} { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte("x"), 0o644) + at := base.Add(time.Duration(i) * time.Hour) + os.Chtimes(p, at, at) + } + got, err := List(folder, 20) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, e := range got { + names = append(names, filepath.Base(e.Path)) + } + if strings.Join(names, ",") != "scan-c.pdf,scan-b.pdf,scan-a.pdf" { + t.Fatalf("list = %v", names) + } + if got, _ = List(folder, 2); len(got) != 2 || filepath.Base(got[0].Path) != "scan-c.pdf" { + t.Fatalf("limit 2 = %v", got) + } + if got, err = List(filepath.Join(folder, "none"), 5); err != nil || len(got) != 0 { + t.Fatalf("missing folder = %v, %v", got, err) + } +} diff --git a/modules/brother/cmd/brother/helpers_test.go b/modules/brother/cmd/brother/helpers_test.go new file mode 100644 index 00000000..1869a718 --- /dev/null +++ b/modules/brother/cmd/brother/helpers_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner. +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "escl", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +// fakeScanner serves the captured eSCL answers and records what it was asked. +type fakeScanner struct { + *httptest.Server + mu sync.Mutex + image []byte + postBody string + posts int + documents int // NextDocument requests + refuse int // when set, the status ScanJobs answers instead of 201 + noPage bool + // A real scanner keeps a job open (and answers a new POST with 503) until its NextDocument has answered 404 + // or the job is DELETEd; the fake does the same. + open bool + delivered bool + pages int // pages a job holds beyond the first + served int + deletes int + gone bool // the first NextDocument answers 410 (the job was cancelled at the device) + dropConn bool // the first NextDocument loses its answer + stuck bool // the job cannot be closed: NextDocument and DELETE both fail +} + +func newFakeScanner(t *testing.T, image []byte) *fakeScanner { + t.Helper() + f := &fakeScanner{image: image} + mux := http.NewServeMux() + mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write(fixture(t, "capabilities.xml")) + }) + mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write(fixture(t, "status-idle.xml")) + }) + mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Method != http.MethodPost { + http.Error(w, "post only", http.StatusMethodNotAllowed) + return + } + b := make([]byte, 1<<16) + n, _ := r.Body.Read(b) + f.postBody = string(b[:n]) + f.posts++ + if f.refuse != 0 { + http.Error(w, "busy", f.refuse) + return + } + if f.open { + http.Error(w, "busy", http.StatusServiceUnavailable) + return + } + f.open, f.delivered, f.served = true, false, 0 + // The captured Location names the real scanner's host; the test's own server stands in for it. + w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5") + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.documents++ + if f.stuck { + http.Error(w, "stuck", http.StatusInternalServerError) + return + } + if f.gone { + f.gone, f.open = false, false + http.Error(w, "gone", http.StatusGone) + return + } + if f.dropConn { + if hj, ok := w.(http.Hijacker); ok { + c, _, _ := hj.Hijack() + c.Close() + } + return + } + if f.noPage || (f.delivered && f.served > f.pages) { + f.open = false + http.NotFound(w, r) + return + } + f.delivered = true + f.served++ + w.Header().Set("Content-Type", "image/jpeg") + w.Write(f.image) + }) + mux.HandleFunc("/eSCL/ScanJobs/7320ead5", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Method != http.MethodDelete { + http.Error(w, "delete only", http.StatusMethodNotAllowed) + return + } + f.deletes++ + if f.stuck { + http.Error(w, "stuck", http.StatusInternalServerError) + return + } + f.open = false + w.WriteHeader(http.StatusOK) + }) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + return f +} + +func (f *fakeScanner) base() string { return f.URL + "/eSCL" } diff --git a/modules/brother/cmd/brother/html.go b/modules/brother/cmd/brother/html.go new file mode 100644 index 00000000..58a16eb0 --- /dev/null +++ b/modules/brother/cmd/brother/html.go @@ -0,0 +1,400 @@ +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"` + Checked *bool `json:"checked,omitempty"` + Label string `json:"label"` + Options []Option `json:"options,omitempty"` + id string + own string // the label of this control alone: