Make printer.print take exactly one of object, path with node, or url, send a machine's file through its node-files, and check the printer's formats first (issue 1598)

This commit is contained in:
2026-10-21 15:05:03 +02:00
parent cb226c6fef
commit e959cf339b
11 changed files with 433 additions and 160 deletions
+10
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"strings"
"sync"
"syscall"
@@ -404,6 +405,15 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
if err != nil {
return nil, err
}
// The printer is asked what it takes before the job, and a format it does not list is refused with the list
// (novox/hq ADR 0468 clause 5): a printer that cannot be asked prints nothing.
listed, err := FormatsOf(ctx, set.Printer)
if err != nil {
return nil, fmt.Errorf("the printer's document formats could not be read, so nothing is sent: %w", err)
}
if !slices.Contains(listed, format) {
return nil, fmt.Errorf("the printer does not take %s: its document-format-supported lists %s", format, strings.Join(listed, ", "))
}
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
+33 -91
View File
@@ -266,15 +266,38 @@ func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
// 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
body []byte // the Print-Job request
status int
// formats is document-format-supported, as the printer lists it; asked counts the Get-Printer-Attributes requests.
formats []string
asked int
}
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
t.Helper()
f := &ippFake{}
f := &ippFake{formats: []string{"application/octet-stream", "application/pdf", "image/jpeg"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.body, _ = io.ReadAll(r.Body)
body, _ := io.ReadAll(r.Body)
if binary.BigEndian.Uint16(body[2:4]) == uint16(ippGetPrinterAttributes) {
// Get-Printer-Attributes (RFC 8011 §4.2.5): the formats are one attribute of several values, a value
// after the first carrying an empty name (§4.1.4), each of the mimeMediaType syntax.
f.asked++
ans := []byte{0x02, 0x00, 0x00, 0x00, 0, 0, 0, 1, ippOperation}
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
ans = append(ans, 0x04) // printer-attributes-tag, RFC 8010 §3.5.1
for i, format := range f.formats {
name := "document-format-supported"
if i > 0 {
name = ""
}
ans = appendAttr(ans, ippMimeType, name, format)
}
ans = append(ans, ippEnd)
w.Header().Set("Content-Type", "application/ipp")
w.Write(ans)
return
}
f.body = 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)
@@ -337,15 +360,14 @@ func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
}
}
// 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) {
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; a document stored under its
// SHA-256 (as node-files.upload keeps one) is printed by its key.
func TestPrinterPrintDefaultsAndFormatsAndStoredDocuments(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"})
r.objects.Put(t.Context(), sha256Hex(jpg), "image/jpeg", jpg)
got, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(jpg)})
if err != nil {
t.Fatal(err)
}
@@ -357,8 +379,8 @@ func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
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)
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex([]byte("never stored"))}); err == nil {
t.Errorf("an object never stored was printed")
}
}
@@ -547,83 +569,3 @@ func home(t *testing.T) string {
t.Setenv("HOME", h)
return h
}
// 1551: printer.print with a path reads the file, uploads it in parts and prints it in one call; a document of
// several parts arrives whole, and the answer has the job's id and state.
func TestPrinterPrintTakesAPathAndUploadsAndPrintsInOneCall(t *testing.T) {
fake := newIPPFake(t, 31, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(2*PartSize + 1234)
file := filepath.Join(home(t), "letter.bin") // the name says nothing: the bytes are read
if err := os.WriteFile(file, doc, 0o600); err != nil {
t.Fatal(err)
}
got, err := r.call(t, "printer.print", map[string]any{"path": file, "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["job-state"] != "processing" || m["format"] != "application/pdf" || m["bytes"] != len(doc) {
t.Errorf("answer = %v", m)
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, doc) || attrs.Attributes["copies"][0].Integer != 2 {
t.Errorf("sent %d bytes (want %d), attributes %v", len(sent), len(doc), attrs.Attributes)
}
ops := r.bus.ops()
if len(ops) != 4 || ops[0] != OpUpload || ops[2] != OpUpload || ops[3] != OpPrint {
t.Errorf("asks = %v, want three upload parts and a print", ops)
}
}
// 1551: only a regular file under the tool account's home folder is read, after symbolic links are resolved; every
// other path is refused in one fixed way that says nothing of whether it exists or how large it is; a file in the
// home folder that is no PDF or JPEG by its bytes is refused by name of the type.
func TestPrinterPrintReadsOnlyAFileUnderTheHomeFolder(t *testing.T) {
fake := newIPPFake(t, 1, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
h := home(t)
outside := t.TempDir()
secret := filepath.Join(outside, "secret.pdf")
os.WriteFile(secret, pdfOf(3000), 0o600)
link := filepath.Join(h, "link.pdf")
os.Symlink(secret, link)
dirLink := filepath.Join(h, "dirlink")
os.Symlink(outside, dirLink)
os.Mkdir(filepath.Join(h, "folder"), 0o700)
for name, path := range map[string]string{
"outside home": secret,
"link out of home": link,
"folder link out": filepath.Join(dirLink, "secret.pdf"),
"dot-dot out of home": filepath.Join(h, "..", filepath.Base(outside), "secret.pdf"),
"missing in home": filepath.Join(h, "none.pdf"),
"missing outside home": filepath.Join(outside, "none.pdf"),
"folder": filepath.Join(h, "folder"),
"the home folder itself": h,
"relative": "secret.pdf",
"proc": "/proc/self/environ",
} {
_, err := r.call(t, "printer.print", map[string]any{"path": path})
if err == nil || err.Error() != PathRefused {
t.Errorf("%s: error = %v, want exactly %q", name, err, PathRefused)
}
}
text := filepath.Join(h, "fake.pdf")
os.WriteFile(text, []byte("just text, named .pdf"), 0o600)
if _, err := r.call(t, "printer.print", map[string]any{"path": text}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
t.Errorf("text in home: error = %v", err)
}
if _, err := r.call(t, "printer.print", map[string]any{"path": text, "object": sha256Hex([]byte("x")) + ".pdf"}); err == nil {
t.Error("path with object accepted")
}
if r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d asks queued, printer sent %d bytes", r.bus.asks(), len(fake.body))
}
// A symbolic link inside the home folder to a file inside it is fine.
inside := filepath.Join(h, "real.pdf")
os.WriteFile(inside, pdfOf(3000), 0o600)
os.Symlink(inside, filepath.Join(h, "ok.pdf"))
if _, err := r.call(t, "printer.print", map[string]any{"path": filepath.Join(h, "ok.pdf")}); err != nil {
t.Errorf("a link within home: %v", err)
}
}
+33
View File
@@ -245,6 +245,39 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
return ans.State()
}
// formatAttributes is what the format check asks for: the document formats the printer takes (RFC 8011 §5.4.22).
var formatAttributes = []string{"document-format-supported"}
// FormatsOf asks the printer, over IPP, which document formats it lists in document-format-supported.
func FormatsOf(ctx context.Context, host string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), formatAttributes)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/ipp")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return nil, 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 nil, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
}
ans, err := DecodeIPP(body)
if err != nil {
return nil, err
}
if ans.Status >= 0x0100 {
return nil, fmt.Errorf("the printer refused the IPP request with status 0x%04x", ans.Status)
}
return ans.texts("document-format-supported"), nil
}
// PrintOptions are what a Print-Job asks of the printer.
type PrintOptions struct {
Format string // document-format, application/pdf or image/jpeg
+17 -19
View File
@@ -11,6 +11,9 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// uploadVia is how the tool side asks a machine's node-files for an upload; a test puts its own in its place.
var uploadVia = sdkUpload
func main() {
if len(os.Args) > 1 && os.Args[1] == "device" {
os.Exit(device())
@@ -84,7 +87,7 @@ 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} {
"data": &a.Data, "sides": &a.Sides, "media": &a.Media, "path": &a.Path, "node": &a.Node, "url": &a.URL} {
if *dst, err = strArg(args, name); err != nil {
return a, err
}
@@ -109,7 +112,8 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
}
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll,
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs}, nil
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs, Upload: uploadVia,
Bucket: func() (string, error) { return ReadBucket(os.Getenv("BROTHER_BUCKET")) }}, 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) {
@@ -147,23 +151,22 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
},
{
Name: "printer.print",
Description: "Print a document on the printer: a file under the home folder of the account the tool runs as, on the machine it runs on (path: reads it, uploads it in parts " +
"and prints it, in one call), a scan kept in the bucket (object), or a completed upload (upload; for a module that sends " +
"bytes over the bus). Only PDF and JPEG, told by the file's bytes and not its name. The device " +
"process sends it as an IPP Print-Job in its own format and answers the job's id and state (with path, the upload id and the size).",
Description: "Print a document on the printer, in one call: a key in the bucket (object: a scan, or a document stored there), a file on any " +
"machine of the mesh (path with node: that machine's node-files sends it into the bucket, a place it keeps a secret is " +
"refused there by name and nothing is printed), or a web link (url). Name exactly one. A Word, spreadsheet or slide " +
"document is turned into a PDF first; otherwise only PDF and JPEG, told by the bytes and not the name, and only a format " +
"the printer lists in document-format-supported. The device process sends it as an IPP Print-Job and answers the job's " +
"id and state. No document crosses the bus.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"path": str("an absolute path of a PDF or JPEG file under the tool account's home folder (symbolic links are resolved and the real path must lie there too; any other path is refused in one fixed way)"),
"object": str("a scan's key, as scan.added names it"),
"upload": str("a completed upload's id"),
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
"path": str("an absolute path of a file on the machine node names"),
"node": str("the machine path is on, such as g14"),
"url": str("an http or https address of a document or a page"),
"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: func(args map[string]any) (any, error) {
path, err := strArg(args, "path")
if err != nil {
return nil, err
}
a, err := askFrom(args)
if err != nil {
return nil, err
@@ -172,14 +175,9 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
if err != nil {
return nil, err
}
if path == "" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return s.PrinterPrint(ctx, a)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
return s.PrintFile(ctx, path, a)
return s.PrinterPrint(ctx, a)
},
},
{
+29 -3
View File
@@ -13,7 +13,8 @@ import (
)
type manifest struct {
Requires []string `json:"requires"`
Requires []requirement `json:"requires"`
Invokes []string `json:"invokes"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
Emits []string `json:"emits"`
@@ -28,6 +29,31 @@ type manifest struct {
} `json:"build"`
}
// requirement is an entry of `requires`: a name, or an object with the name and the shares of the bucket.
type requirement struct {
Name string `json:"name"`
Shares []struct {
To string `json:"to"`
Methods []string `json:"methods"`
} `json:"shares"`
}
func (r *requirement) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(b, &r.Name); err == nil {
return nil
}
type plain requirement
return json.Unmarshal(b, (*plain)(r))
}
func (m manifest) requireNames() []string {
var out []string
for _, r := range m.Requires {
out = append(out, r.Name)
}
return out
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
@@ -330,8 +356,8 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
// written into store.json, a file only the device account can read; the tools' bundle is not told where it is.
func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) {
t.Errorf("requires = %v", m.Requires)
if !reflect.DeepEqual(m.requireNames(), []string{"s3-bucket"}) {
t.Errorf("requires = %v", m.requireNames())
}
if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" {
t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets)
+72 -45
View File
@@ -88,6 +88,12 @@ type Ask struct {
Document string `json:"document,omitempty"` // scan: start, or an open document's id: a glass page into it
Finish bool `json:"finish,omitempty"` // scan: close the document named in Document into one PDF
// Path with Node, and URL, are what printer.print is asked: the tool side turns them into an Object before
// anything is queued, so the queued ask carries a key and the options and never these (novox/hq ADR 0468 clause 5).
Path string `json:"-"` // print: a file on the machine Node
Node string `json:"-"` // print: the machine Path is on
URL string `json:"-"` // print: a web page or document to fetch (issue 1599)
}
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
@@ -211,9 +217,12 @@ func validPage(page string) bool {
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}$`)
// printKeyPattern is a document the bucket keeps under its SHA-256: a scan's key, or the key node-files.upload
// answers for a file sent from a machine (the bare SHA-256).
printKeyPattern = 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.
@@ -259,14 +268,23 @@ func checkUpload(a Ask) error {
// 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.Upload != "" {
return fmt.Errorf("upload is no longer a source of printer.print: send the file with path and node, or name its key in object")
}
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.Path == "") != (a.Node == "") {
return fmt.Errorf("path and node go together: path is a file on the machine node names")
}
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)
sources := 0
for _, given := range []bool{a.Object != "", a.Path != "", a.URL != ""} {
if given {
sources++
}
}
if sources != 1 {
return fmt.Errorf("name exactly one of object (a key in the bucket), path with node (a file on that machine) or url, not %s", map[bool]string{true: "none", false: "two or more"}[sources == 0])
}
if a.Object != "" && !printKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a stored document's key: 64 hex digits, with .pdf for a scan", a.Object)
}
if a.Copies == 0 {
a.Copies = 1
@@ -307,49 +325,32 @@ func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, err
return s.call(ctx, a, s.AnswerWait)
}
// PrintFile prints a PDF or a JPEG that lies on the machine the tool runs on: it reads the file, uploads it in
// parts under an upload id of its own and prints that, in one call (issue 1551). The type is read from the
// file's first bytes, never its name; any other file is refused before anything is queued. The answer is the
// print's, with the upload id and the file's size.
func (s *Service) PrintFile(ctx context.Context, path string, a Ask) (map[string]any, error) {
if a.Object != "" || a.Upload != "" {
return nil, fmt.Errorf("name path, or object, or upload: one of the three")
}
// The path is checked first, and refused in one fixed way that tells nothing about the file.
doc, err := ReadDocument(path, MostParts*PartSize)
// sdkUpload asks node's node-files to send a file of that machine into the bucket and answers the key it was
// stored under (issue 1597). The form is the seat's verb on one machine, `seat:<seat>.<verb>@<node>`.
func sdkUpload(node, path, bucket string) (string, error) {
raw, err := stdio.Ask("seat:node-files.upload@"+node, map[string]any{"path": path, "bucket": bucket})
if err != nil {
return nil, err
return "", err
}
a.Upload = "p-" + newAskID()
if err := checkPrint(&a); err != nil {
return nil, err
var ans struct {
Key string `json:"key"`
Answer json.RawMessage `json:"answer"`
}
if s.Settings.Printer == "" {
return nil, unsetError("printer")
if err := json.Unmarshal(raw, &ans); err != nil {
return "", fmt.Errorf("node-files' answer is not JSON: %w", err)
}
if _, _, err := SniffFormat(doc); err != nil {
return nil, fmt.Errorf("file %s: %w", path, err)
if ans.Key == "" && len(ans.Answer) > 0 {
_ = json.Unmarshal(ans.Answer, &ans)
}
sum, n := sha256Hex(doc), partCount(len(doc))
for i := 1; i <= n; i++ {
part, _, _ := cutPartOf(doc, i, PartSize)
up, err := s.PrinterUpload(ctx, Ask{Upload: a.Upload, Part: i, Parts: n, SHA256: sum, Data: base64.StdEncoding.EncodeToString(part)})
if err != nil {
return nil, fmt.Errorf("part %d of %d of %s could not be uploaded: %w", i, n, path, err)
}
if i == n && up["complete"] != true {
return nil, fmt.Errorf("the upload of %s is not complete after its last part: %v", path, up)
}
if ans.Key == "" {
return "", fmt.Errorf("node-files' answer names no key")
}
out, err := s.PrinterPrint(ctx, a)
if err != nil {
return nil, fmt.Errorf("%s is uploaded as %s but could not be printed: %w", path, a.Upload, err)
}
out["path"], out["upload"], out["bytes"] = path, a.Upload, len(doc)
return out, nil
return ans.Key, nil
}
// PrinterPrint asks the device process to print a kept scan or a completed upload.
// PrinterPrint prints a stored document, a file of a machine, or a web link, in one call. A file of a machine is first
// sent into the bucket by that machine's node-files (refused there by name when it is a secret, and then nothing is
// printed); the print ask the device process takes carries the key and the options and no bytes.
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
a.Op = OpPrint
if err := checkPrint(&a); err != nil {
@@ -358,7 +359,33 @@ func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, erro
if s.Settings.Printer == "" {
return nil, unsetError("printer")
}
return s.call(ctx, a, s.AnswerWait)
from := map[string]any{}
switch {
case a.URL != "":
return nil, fmt.Errorf("printing a url is not served yet (issue 1599)")
case a.Path != "":
if s.Upload == nil {
return nil, fmt.Errorf("this module has no way to ask node-files for an upload")
}
bucket, err := s.Bucket()
if err != nil {
return nil, err
}
key, err := s.Upload(a.Node, a.Path, bucket)
if err != nil {
return nil, fmt.Errorf("%s on %s could not be sent to the bucket, and nothing is printed: %w", a.Path, a.Node, err)
}
from["path"], from["node"] = a.Path, a.Node
a.Object, a.Path, a.Node = key, "", ""
}
out, err := s.call(ctx, a, s.AnswerWait)
if err != nil {
return nil, err
}
for k, v := range from {
out[k] = v
}
return out, nil
}
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
@@ -0,0 +1,186 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// uploadFake stands for node-files.upload on another machine: it records the ask and answers a key, or refuses.
type uploadFake struct {
calls []string
key string
err error
}
// withUpload puts a fake node-files in the tool side's place, and the bucket file the mesh writes from the binding
// (files/bucket.json) where the tool reads it.
func withUpload(t *testing.T, key string, err error) *uploadFake {
t.Helper()
f := &uploadFake{key: key, err: err}
old := uploadVia
uploadVia = func(node, path, bucket string) (string, error) {
f.calls = append(f.calls, node+"|"+path+"|"+bucket)
return f.key, f.err
}
t.Cleanup(func() { uploadVia = old })
file := filepath.Join(t.TempDir(), "bucket.json")
if err := os.WriteFile(file, []byte(`{"bucket": "brother-test"}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("BROTHER_BUCKET", file)
return f
}
// 1598/AC1: none, or two, of object, path+node and url is refused naming the rule; path without node is refused.
func TestPrintTakesExactlyOneSource(t *testing.T) {
fake := newIPPFake(t, 1, 3)
up := withUpload(t, "k", nil)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
key := sha256Hex([]byte("x")) + ".pdf"
for name, args := range map[string]map[string]any{
"none": {},
"only options": {"copies": float64(2)},
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"path without node": {"path": "/home/ops/a.pdf"},
"node without path": {"node": "g14", "object": key},
"the retired upload": {"upload": "u1"},
"object and upload": {"object": key, "upload": "u1"},
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
} {
_, err := r.call(t, "printer.print", args)
if err == nil {
t.Errorf("%s: accepted", name)
continue
}
rule := strings.Contains(err.Error(), "exactly one of object") || strings.Contains(err.Error(), "path and node go together") ||
strings.Contains(err.Error(), "upload is no longer")
if !rule {
t.Errorf("%s: the refusal does not name the rule: %v", name, err)
}
}
if r.bus.asks() != 0 || len(up.calls) != 0 || fake.body != nil {
t.Errorf("%d asks queued, %d uploads asked, printer sent %d bytes", r.bus.asks(), len(up.calls), len(fake.body))
}
}
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
fake := newIPPFake(t, 31, 5)
doc := pdfOf(6000)
key := sha256Hex(doc)
up := withUpload(t, key, nil)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
if len(up.calls) != 1 || up.calls[0] != "g14|/home/ops/letter.pdf|brother-test" {
t.Fatalf("uploads asked: %v", up.calls)
}
if len(r.bus.published) != 1 {
t.Fatalf("%d asks queued", len(r.bus.published))
}
queued := r.bus.published[0]
raw, _ := json.Marshal(queued)
var fields map[string]any
json.Unmarshal(raw, &fields)
if queued.Op != OpPrint || queued.Object != key || queued.Copies != 2 || len(raw) >= 4096 {
t.Errorf("queued %s", raw)
}
for _, banned := range []string{"path", "node", "url", "data"} {
if _, has := fields[banned]; has {
t.Errorf("the queued ask carries %q: %s", banned, raw)
}
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
t.Errorf("answer = %v", m)
}
if _, sent := fake.request(t); string(sent) != string(doc) {
t.Errorf("the printer was not sent the stored document")
}
}
// 1598/AC3: an upload refused by node-files is answered with node-files' reason, and nothing is printed.
func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T) {
fake := newIPPFake(t, 1, 3)
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
t.Fatalf("error = %v", err)
}
if len(up.calls) != 1 || r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d uploads, %d asks queued, printer sent %d bytes", len(up.calls), r.bus.asks(), len(fake.body))
}
}
// 1598/AC4: the device reads document-format-supported before the job and refuses a format the printer does not
// list, naming the list; a printer that cannot be asked is sent nothing.
func TestTheDeviceChecksTheFormatsThePrinterListsBeforeTheJob(t *testing.T) {
fake := newIPPFake(t, 5, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(300)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
t.Fatal(err)
}
if fake.asked != 1 || fake.body == nil {
t.Fatalf("the formats were asked %d times, the job was sent: %v", fake.asked, fake.body != nil)
}
fake.body, fake.formats = nil, []string{"image/urf", "image/jpeg"}
_, err := r.call(t, "printer.print", map[string]any{"object": key})
if err == nil || !strings.Contains(err.Error(), "application/pdf") || !strings.Contains(err.Error(), "image/urf, image/jpeg") {
t.Fatalf("a format not listed: error = %v", err)
}
if fake.body != nil {
t.Error("a job was sent in a format the printer does not list")
}
fake.formats = nil
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || fake.body != nil {
t.Errorf("a printer that lists nothing: error = %v, sent %v", err, fake.body != nil)
}
}
// 1598/AC5: brother's s3-bucket requirement shares put with seat:node-files; brother invokes seat:node-files.upload;
// the bucket's name reaches the tool side through a file the mesh writes from the binding.
func TestManifestSharesPutWithNodeFilesAndInvokesItsUpload(t *testing.T) {
m := readManifest(t)
if len(m.Requires) != 1 || m.Requires[0].Name != "s3-bucket" || len(m.Requires[0].Shares) != 1 ||
m.Requires[0].Shares[0].To != "seat:node-files" || strings.Join(m.Requires[0].Shares[0].Methods, ",") != "put" {
t.Errorf("requires = %+v", m.Requires)
}
if len(m.Invokes) != 1 || m.Invokes[0] != "seat:node-files.upload" {
t.Errorf("invokes = %v", m.Invokes)
}
var bucket map[string]any
for _, r := range m.Resources {
if r["id"] == "bucket" {
bucket = r
}
}
if bucket == nil || bucket["path"] != "${dir:state}/bucket.json" || bucket["template"] != "files/bucket.json" {
t.Fatalf("bucket resource = %v", bucket)
}
raw, _ := os.ReadFile("../../files/bucket.json")
if !strings.Contains(string(raw), "${bound:s3-bucket:bucket}") || strings.Contains(string(raw), "secret") {
t.Errorf("files/bucket.json = %s", raw)
}
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" && a["env"].(map[string]any)["BROTHER_BUCKET"] != "${dir:state}/bucket.json" {
t.Errorf("the tools bundle is not given BROTHER_BUCKET: %v", a["env"])
}
}
}
+4
View File
@@ -32,6 +32,10 @@ type Service struct {
QuickWait, FeederWait time.Duration
// Jobs are the scans this process follows.
Jobs *Jobs
// Upload asks a machine's node-files to send one of its files into the bucket and answers the key; Bucket is
// brother's bucket. Both are the tool side's (printer.print with path and node).
Upload func(node, path, bucket string) (string, error)
Bucket func() (string, error)
}
// Jobs are the scans this tool process has started and not yet been asked the outcome of. A scan runs in the
+23
View File
@@ -108,3 +108,26 @@ func (s Settings) MissingPrinter() 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(unset, " and "))
}
// bucketFile is the file the mesh writes from the s3-bucket binding (files/bucket.json): the bucket's name and no secret.
// BucketFile is where the tool side reads brother's bucket from: a file the mesh writes from the s3-bucket binding
// (files/bucket.json), which holds the bucket's name and no secret.
type bucketFile struct {
Bucket string `json:"bucket"`
}
// ReadBucket is brother's bucket, as the mesh named it.
func ReadBucket(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("the module was started without the bucket's name (BROTHER_BUCKET)")
}
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("the bucket's name cannot be read: %w", err)
}
var f bucketFile
if err := json.Unmarshal(raw, &f); err != nil || f.Bucket == "" {
return "", fmt.Errorf("the bucket file %s names no bucket", path)
}
return f.Bucket, nil
}
+3
View File
@@ -0,0 +1,3 @@
{
"bucket": "${bound:s3-bucket:bucket}"
}
+23 -2
View File
@@ -2,7 +2,20 @@
"module": "brother",
"version": "1",
"requires": [
"s3-bucket"
{
"name": "s3-bucket",
"shares": [
{
"to": "seat:node-files",
"methods": [
"put"
]
}
]
}
],
"invokes": [
"seat:node-files.upload"
],
"binds": {
"s3-bucket": "${dir:state}/store-binding.json"
@@ -199,6 +212,13 @@
"mode": "0600",
"template": "files/store.json"
},
{
"id": "bucket",
"type": "file",
"path": "${dir:state}/bucket.json",
"mode": "0644",
"template": "files/bucket.json"
},
{
"id": "device",
"type": "process",
@@ -267,7 +287,8 @@
"brother"
],
"env": {
"BROTHER_SETTINGS": "${dir:state}/settings.json"
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_BUCKET": "${dir:state}/bucket.json"
}
},
{