brother: scanning moves into the device process and its scans are kept in the bucket; scanner.document, printer.upload and printer.print carry documents in parts and print over IPP (issue 1453, ADR 0468)

This commit is contained in:
2026-10-20 21:30:51 +02:00
parent d6e88fac17
commit 1d62305001
17 changed files with 1609 additions and 335 deletions
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"math"
"time"
)
// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of
// the platen's white and built into a one-page PDF. Keeping it in the bucket and saying scan.added are the
// device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go).
func mm(pixels, dpi int) float64 {
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10
}
// Capture is a scanned, cropped page as a PDF, with what the event says of it.
type Capture struct {
PDF []byte
SHA256 string
WidthMM float64
HeightMM float64
Resolution int
Cropped bool
At time.Time
}
// Capture scans the glass, crops and builds the PDF. Nothing is kept and nothing is said here; an error says why
// the scanner gave no page. The resolution is the image's own, never the one asked (1440/AC2).
func (s *Service) Capture(ctx context.Context) (Capture, error) {
at := s.Now()
scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode)
if err != nil {
return Capture{}, err
}
dpi, err := Density(scan)
if err != nil {
return Capture{}, err
}
cut, err := Crop(scan, s.Settings.Crop)
if err != nil {
return Capture{}, err
}
pdf, err := PDF(cut.JPEG, dpi)
if err != nil {
return Capture{}, err
}
return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(cut.Width, dpi), HeightMM: mm(cut.Height, dpi),
Resolution: dpi, Cropped: cut.Cropped, At: at}, nil
}
// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image,
// and no path, since filing in a folder is the tool's, after the event.
func (c Capture) Body(node, colorMode string) map[string]any {
return map[string]any{
"object": c.SHA256 + ".pdf",
"node": node,
"bytes": len(c.PDF),
"sha256": c.SHA256,
"width-mm": c.WidthMM,
"height-mm": c.HeightMM,
"resolution": c.Resolution,
"color-mode": colorMode,
"cropped": c.Cropped,
"scanned-at": c.At.UTC().Format(time.RFC3339),
}
}
@@ -17,7 +17,7 @@ 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"}
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) {
@@ -36,8 +36,10 @@ func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), `Getenv("BROTHER_ADMIN_PASSWORD")`) {
t.Errorf("%s reads BROTHER_ADMIN_PASSWORD, the device process's alone", file)
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)
}
}
}
}
@@ -59,9 +61,9 @@ func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) {
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") {
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" {
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)
}
}
@@ -89,7 +89,7 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
done := make(chan error, 1)
go func() { done <- runDevice(runCtx, account, w) }()
body, _ := json.Marshal(Ask{ID: "ask-1", Page: "/general/other.html", At: time.Now()})
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)
}
+256 -20
View File
@@ -7,11 +7,13 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
@@ -33,16 +35,38 @@ 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
)
// deviceWorker answers the asks. Settings and the password are read at each ask, so a changed config value or a
// password given later is the next ask's.
// 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)
Answers Answers
Now func() time.Time
Logf func(format string, a ...any)
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) {
@@ -51,35 +75,229 @@ func (w *deviceWorker) logf(format string, a ...any) {
}
}
// 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 printer not contacted; 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).
func (w *deviceWorker) Handle(body []byte) (action, time.Duration) {
// 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 w.Now().Sub(ask.At) > StaleAfter {
w.logf("[brother] device: ask %s is %s old; nobody waits for it, the printer is not contacted", ask.ID, w.Now().Sub(ask.At).Round(time.Second))
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
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
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 page, err := w.read(ctx, ask.Page); err != nil {
if got, err := w.run(ctx, ask); err != nil {
answer = map[string]any{"error": err.Error()}
} else {
answer = page
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).
@@ -143,10 +361,11 @@ func nonNilForms(f []Form) []Form {
return f
}
// admin is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped.
// 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
@@ -161,8 +380,15 @@ func device() int {
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(settings) },
Password: func() (string, error) { return secret.Read(passwordFile) },
Now: time.Now,
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
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)
@@ -175,7 +401,7 @@ func device() int {
// 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
// admin so a test can drive it over a bus of its own.
// 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 {
@@ -183,8 +409,18 @@ func runDevice(ctx context.Context, account process.Account, w *deviceWorker) er
}
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) {
act, delay := w.Handle(msg.Body)
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:
@@ -0,0 +1,440 @@
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 768 KiB 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)
}
}
+116 -7
View File
@@ -12,16 +12,20 @@ import (
"time"
)
// IPP (RFC 8010, RFC 8011) by hand: one operation, Get-Printer-Attributes, which needs no login.
// 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
)
@@ -94,9 +98,15 @@ type IPPAnswer struct {
// 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, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b))
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
@@ -105,26 +115,26 @@ func DecodeIPP(b []byte) (IPPAnswer, error) {
tag := b[i]
i++
if tag == ippEnd {
return a, nil
return a, i, nil
}
if tag <= 0x05 { // a group's start
last = ""
continue
}
if i+2 > len(b) {
return a, fmt.Errorf("the IPP answer ends inside an attribute")
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, fmt.Errorf("the IPP answer ends inside an attribute name")
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, fmt.Errorf("the IPP answer ends inside the value of %q", name)
return a, 0, fmt.Errorf("the IPP answer ends inside the value of %q", name)
}
raw := b[i : i+vl]
i += vl
@@ -140,7 +150,7 @@ func DecodeIPP(b []byte) (IPPAnswer, error) {
}
a.Attributes[name] = append(a.Attributes[name], v)
}
return a, fmt.Errorf("the IPP answer has no end-of-attributes tag")
return a, 0, fmt.Errorf("the IPP answer has no end-of-attributes tag")
}
func (a IPPAnswer) texts(name string) []string {
@@ -234,3 +244,102 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
}
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()
}
+3 -3
View File
@@ -91,7 +91,7 @@ func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) {
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()+`"}`), nil, time.Now)
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)
@@ -121,7 +121,7 @@ func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) {
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()+`"}`), nil, time.Now)
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)
@@ -135,7 +135,7 @@ func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) {
{`{"printer":"192.0.2.10"}`, "scanner is not set"},
{`{}`, "printer and scanner"},
} {
tl := tools(settingsFile(t, c.js), nil, time.Now)
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)
}
+119 -14
View File
@@ -13,29 +13,131 @@ func main() {
if len(os.Args) > 1 && os.Args[1] == "device" {
os.Exit(device())
}
if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), emit, time.Now)); err != nil {
if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), 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})
// 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)
}
// tools are the scanner seat's three verbs, served as <seat>.<verb> (ADR 0467 clause 2). 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 {
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, Emit: say, Asks: askBus,
AnswerWait: answerWait, AnswerPoll: answerPoll}, nil
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 (768 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 " +
@@ -86,16 +188,19 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool
},
{
Name: "scanner.scan",
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.",
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
}
return s.ScanNow(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute)
defer cancel()
return s.ScanNow(ctx)
},
},
{
+24 -7
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"testing"
)
@@ -62,15 +64,30 @@ func TestTheScanFolderIsNoManifestResource(t *testing.T) {
// 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)
if len(m.Emits) != 0 {
t.Errorf("the module's own emits = %v: scan.added is said by the device process, whose bus block declares it", m.Emits)
}
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, nil) {
for _, tl := range tools("", nil) {
served = append(served, tl.Name)
}
if !reflect.DeepEqual(m.Tools, served) {
t.Errorf("manifest tools = %v, the bundle serves %v", m.Tools, served)
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)
}
}
}
}
@@ -142,9 +159,9 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) {
m := readManifest(t)
for _, want := range []seat{
{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}},
{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"}},
{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
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// Documents cross the bus in parts (novox/hq ADR 0468 clause 3): the bus refuses a message over 1 MiB, and a
// part travels base64-encoded inside a JSON message.
const (
// MaxPart is the most raw bytes a part may hold; a part of more is refused.
MaxPart = 768 << 10
// PartSize is how large the parts this module cuts are: below MaxPart, so that the part's base64 text and
// the message around it stay under the bus's 1 MiB.
PartSize = 700 << 10
// 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
}
+169 -32
View File
@@ -3,10 +3,12 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"time"
@@ -20,10 +22,9 @@ 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 ask printer.settings puts to the device process (ADR 0467 clause 4; the shape of ADR 0436's mail-sends):
// the tool never holds the admin password, so it queues the ask on the work-queue stream device-asks and the
// device process, an account of its own that alone reads the password, answers under the ask's id in the state
// device-answers.
// 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"
@@ -35,21 +36,42 @@ const (
// 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 printer.settings waits for the device process.
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
AnswerWait = 60 * time.Second
// StaleAfter is how old an ask may be when the device process takes it: older than twice the wait, nobody waits
// for it any more, so the printer is not contacted for it.
StaleAfter = 2 * AnswerWait
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
)
// Ask is what the tool queues: which page, and when it was asked.
// 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"`
Page string `json:"page"`
At time.Time `json:"asked-at"`
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 tool reaches the device process: it queues an ask and reads an answer back.
// 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)
@@ -69,9 +91,10 @@ func (sdkAskBus) Answer(id string) (json.RawMessage, error) {
return e.Value, nil
}
// answerWait and answerPoll are what the tools wait with; a test shortens them.
// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them.
var (
answerWait = AnswerWait
scanWait = ScanWait
answerPoll = 500 * time.Millisecond
)
@@ -84,27 +107,17 @@ func newAskID() string {
return hex.EncodeToString(b)
}
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
// its answer. It checks the page and that printer and printer-certificate are set, and reads, opens and sends
// nothing of the admin password (ADR 0467 clause 4; ADR 0436 §12): nothing is queued before those checks pass.
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
}
wait, poll := s.AnswerWait, s.AnswerPoll
// 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 := Ask{ID: newAskID(), Page: page, At: s.Now().UTC()}
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)
}
@@ -127,17 +140,142 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the printer is slow (ask %s)",
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 (768 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.
@@ -150,8 +288,7 @@ func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) {
unset = append(unset, "scanner")
}
if len(unset) > 0 {
return nil, 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 "))
return nil, unsetError(unset...)
}
out := map[string]any{}
if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil {
+8 -115
View File
@@ -6,119 +6,12 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"brother/cmd/brother/internal/secret"
)
// 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)
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 tool 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 tool reads.
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)
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)
}
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)
}
// printerRig is the tool, the bus and the admin worker over a settings file for the fake printer, with the
// secret in a file the way the mesh writes it (a trailing newline included); an empty password writes none.
type printerRig struct {
run func(args map[string]any) (any, error)
bus *fakeAskBus
worker *deviceWorker
answers *memAnswers
}
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 := &memAnswers{}
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(file) },
Password: func() (string, error) { return secret.Read(secretFile) },
Answers: answers,
Now: time.Now,
}
bus := &fakeAskBus{worker: w, answers: answers}
old, oldWait, oldPoll := askBus, answerWait, answerPoll
askBus, answerWait, answerPoll = bus, 5*time.Second, 5*time.Millisecond
t.Cleanup(func() { askBus, answerWait, answerPoll = old, oldWait, oldPoll })
tl := tools(file, nil, time.Now)
return &printerRig{run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers}
}
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
}
// 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/
@@ -296,25 +189,25 @@ 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")); act != actionTerm {
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", Page: DefaultPage, At: time.Now().Add(-StaleAfter - time.Second)})
if act, _ := w.Handle(old); act != actionAck || r.answers.get("old") != nil || f.conns != 0 {
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.conns != 0 {
t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.conns)
}
fresh, _ := json.Marshal(Ask{ID: "a1", Page: DefaultPage, At: time.Now()})
if act, _ := w.Handle(fresh); act != actionAck || r.answers.get("a1") == nil {
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.conns
if act, _ := w.Handle(fresh); act != actionAck {
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", Page: DefaultPage, At: time.Now()})
if act, delay := w.Handle(again); act != actionRetry || delay <= 0 {
again, _ := json.Marshal(Ask{ID: "a2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
if act, delay := w.Handle(again, nil); act != actionRetry || delay <= 0 {
t.Errorf("an answer that cannot be written: action %v delay %v, want retry", act, delay)
}
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"brother/cmd/brother/internal/secret"
)
// fixedNow is the time the tools and the device worker of a rig read.
var fixedNow = time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local)
// memAnswers is the state device-answers, in memory.
type memAnswers struct {
mu sync.Mutex
m map[string]json.RawMessage
// fail, when set, is the error every Create answers.
fail error
}
func (a *memAnswers) Create(key string, v any) (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fail != nil {
return 0, a.fail
}
if _, ok := a.m[key]; ok {
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
}
if a.m == nil {
a.m = map[string]json.RawMessage{}
}
raw, _ := json.Marshal(v)
if len(raw) > 1<<20 {
return 0, fmt.Errorf("an answer of %d bytes is over the bus's 1 MiB", len(raw))
}
a.m[key] = raw
return 1, nil
}
func (a *memAnswers) get(key string) json.RawMessage {
a.mu.Lock()
defer a.mu.Unlock()
return a.m[key]
}
// fakeAskBus is the bus between the tools and the device process: an ask published on it is handed to the
// worker, as the work queue hands it to the device process, and the worker's answers are what the tools read.
type fakeAskBus struct {
worker *deviceWorker // nil: nobody consumes the queue
answers *memAnswers
mu sync.Mutex
published []Ask
}
func (b *fakeAskBus) Publish(id string, body any) error {
raw, _ := json.Marshal(body)
if len(raw) > 1<<20 {
return fmt.Errorf("a message of %d bytes is over the bus's 1 MiB", len(raw))
}
var ask Ask
json.Unmarshal(raw, &ask)
b.mu.Lock()
b.published = append(b.published, ask)
b.mu.Unlock()
if b.worker != nil {
go b.worker.Handle(raw, nil)
}
return nil
}
func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil }
func (b *fakeAskBus) asks() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.published)
}
func (b *fakeAskBus) ops() []string {
b.mu.Lock()
defer b.mu.Unlock()
var o []string
for _, a := range b.published {
o = append(o, a.Op)
}
return o
}
// printerRig is the tools, the bus and the device worker over a settings file, with the secret in a file the way
// the mesh writes it (a trailing newline included; an empty password writes none) and the bucket in memory.
type printerRig struct {
tl []stdio.Tool
run func(args map[string]any) (any, error) // printer.settings
bus *fakeAskBus
worker *deviceWorker
answers *memAnswers
objects *memObjects
said *[]said
mu *sync.Mutex
}
// call runs a tool by name.
func (r *printerRig) call(t *testing.T, name string, args map[string]any) (any, error) {
t.Helper()
return tool(t, r.tl, name).Run(args)
}
func newRig(t *testing.T, settings, password string) *printerRig {
t.Helper()
secretFile := filepath.Join(t.TempDir(), "admin-password.secret")
if password != "" {
if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
file := settingsFile(t, settings)
answers, objects := &memAnswers{}, newMemObjects()
var out []said
var mu sync.Mutex
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(file) },
Password: func() (string, error) { return secret.Read(secretFile) },
Objects: func() (Objects, error) { return objects, nil },
Emit: func(body map[string]any, id string) error {
mu.Lock()
defer mu.Unlock()
_, err := objects.Get(t.Context(), body["object"].(string))
out = append(out, said{body, id, err == nil})
return nil
},
Answers: answers,
Node: "shanks",
Now: func() time.Time { return fixedNow },
}
bus := &fakeAskBus{worker: w, answers: answers}
old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll
askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond
t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll })
tl := tools(file, func() time.Time { return fixedNow })
return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu}
}
func printerSettingsJSON(f *fakePrinter) string {
return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`
}
func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) {
t.Helper()
js := printerSettingsJSON(f)
return js, newRig(t, js, password).run
}
+54 -48
View File
@@ -2,85 +2,91 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/base64"
"fmt"
"math"
"time"
)
// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare brother.scan.added; it
// travels as mesh.mod.brother.event.scan.added. It names the file and never carries the image.
// 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"
// 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.
// 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
Emit Emitter
// Asks is how printer.settings reaches the device process, which alone logs in to the printer.
// Asks is how the verbs reach the device process, which alone holds a credential.
Asks AskBus
// AnswerWait is how long printer.settings waits for the device process's answer; AnswerPoll how often it looks.
AnswerWait, AnswerPoll time.Duration
// 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
}
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).
// 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
}
at := s.Now()
scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode)
ans, err := s.call(ctx, Ask{Op: OpScan}, s.ScanWait)
if err != nil {
return nil, err
}
// The resolution is the image's own, never the one asked (1440/AC2).
dpi, err := Density(scan)
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, err
return nil, fmt.Errorf("the scan is kept as %s: %w", key, err)
}
cut, err := Crop(scan, s.Settings.Crop)
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
if err != nil {
return nil, err
at = s.Now()
}
pdf, err := PDF(cut.JPEG, dpi)
path, err := File(s.Settings.Folder, pdf, at.Local())
if err != nil {
return nil, err
return nil, fmt.Errorf("the scan is kept as %s, but could not be filed: %w", key, 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)
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) {
+102 -73
View File
@@ -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)
r := newRig(t, js, "pw")
out := *r.said
for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} {
_, err := tool(t, tl, name).Run(map[string]any{})
_, 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,7 +260,7 @@ 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)
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,7 +293,7 @@ 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)
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)
@@ -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, "scanner.scan").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)
}
}
+6
View File
@@ -86,6 +86,12 @@ func (s Settings) Missing() error {
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 {
+19 -10
View File
@@ -10,9 +10,6 @@
"secrets": {
"s3-bucket": "${dir:state}/store.secret"
},
"emits": [
"scan.added"
],
"config": {
"scanner": {
"type": "string",
@@ -108,7 +105,8 @@
"serves": [
"scan",
"status",
"list"
"list",
"document"
]
},
{
@@ -116,7 +114,9 @@
"scope": "mesh",
"serves": [
"status",
"settings"
"settings",
"print",
"upload"
]
}
],
@@ -127,7 +127,8 @@
"serves": [
"scan",
"status",
"list"
"list",
"document"
]
},
{
@@ -135,16 +136,21 @@
"scope": "mesh",
"serves": [
"status",
"settings"
"settings",
"print",
"upload"
]
}
],
"tools": [
"printer.status",
"printer.settings",
"scanner.scan",
"scanner.status",
"scanner.list"
"scanner.list",
"scanner.document",
"printer.status",
"printer.settings",
"printer.print",
"printer.upload"
],
"resources": [
{
@@ -213,6 +219,9 @@
],
"state": [
"device-answers"
],
"emits": [
"scan.added"
]
},
"health": {