diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go index 38a95284..b5c65ea5 100644 --- a/modules/brother/cmd/brother/deviceproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -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 diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go index de3ce8d1..f58d318b 100644 --- a/modules/brother/cmd/brother/documents_test.go +++ b/modules/brother/cmd/brother/documents_test.go @@ -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) - } -} diff --git a/modules/brother/cmd/brother/ipp.go b/modules/brother/cmd/brother/ipp.go index 5296650c..449b8b33 100644 --- a/modules/brother/cmd/brother/ipp.go +++ b/modules/brother/cmd/brother/ipp.go @@ -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 diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index fe676e4f..a1a7709a 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -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) }, }, { diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 6782b773..1e61a776 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -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) diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index a7acc06e..67b8268a 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -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:.@`. +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 diff --git a/modules/brother/cmd/brother/printlink_test.go b/modules/brother/cmd/brother/printlink_test.go new file mode 100644 index 00000000..6c532e3e --- /dev/null +++ b/modules/brother/cmd/brother/printlink_test.go @@ -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"]) + } + } +} diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index 265129c9..e8ddf14b 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -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 diff --git a/modules/brother/cmd/brother/settings.go b/modules/brother/cmd/brother/settings.go index b8f9c616..8bd31e19 100644 --- a/modules/brother/cmd/brother/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -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 +} diff --git a/modules/brother/files/bucket.json b/modules/brother/files/bucket.json new file mode 100644 index 00000000..b72b2331 --- /dev/null +++ b/modules/brother/files/bucket.json @@ -0,0 +1,3 @@ +{ + "bucket": "${bound:s3-bucket:bucket}" +} diff --git a/modules/brother/module.json b/modules/brother/module.json index 57559d56..2e12f09c 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -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" } }, {