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