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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"bucket": "${bound:s3-bucket:bucket}"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user