Answer each settings field's current value, and leave the menu links and hidden fields out unless asked (issue 1530)

This commit is contained in:
2026-10-21 00:08:58 +02:00
parent 199f72fed2
commit 3df52a8ac5
8 changed files with 294 additions and 23 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ makes it (mode 0755) when it files the first scan.
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
| `printer.print` | prints a kept scan (`object`) or a completed upload (`upload`) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state |
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
### Trust and the password
+32 -6
View File
@@ -120,7 +120,7 @@ func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration)
func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) {
switch ask.Op {
case OpSettings:
return w.read(ctx, ask.Page)
return w.read(ctx, ask.Page, ask.Links, ask.Hidden)
case OpScan:
return w.scan(ctx)
case OpDocument:
@@ -301,7 +301,7 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
// the only things it sends, and a password field's value is never in the answer. The printer is not contacted
// before the settings and the password are known to be given (1453/AC4).
func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, error) {
func (w *deviceWorker) read(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
@@ -342,13 +342,39 @@ func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, e
}
logout := a.Logout(lctx, body, at)
p := ParsePage(body)
return map[string]any{
out := map[string]any{
"page": page,
"title": p.Title,
"links": nonNil(p.Links),
"forms": nonNilForms(p.Forms),
"forms": nonNilForms(withoutHidden(p.Forms, hidden)),
"logout": logout,
}, nil
}
if links {
out["links"] = nonNil(p.Links)
}
return out, nil
}
// withoutHidden leaves the hidden fields (the CSRF token and the like, noise to a reader) out of the forms
// unless they are asked for.
func withoutHidden(forms []Form, keep bool) []Form {
if keep {
return forms
}
out := make([]Form, 0, len(forms))
for _, f := range forms {
var fields []Field
for _, fl := range f.Fields {
if fl.Type != "hidden" {
fields = append(fields, fl)
}
}
if len(fields) == 0 && len(f.Fields) > 0 {
continue // a form of nothing but hidden fields (the logout form) has nothing to read
}
f.Fields = fields
out = append(out, f)
}
return out
}
func nonNil(l []Link) []Link {
+90 -6
View File
@@ -21,9 +21,19 @@ type Field struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
Checked *bool `json:"checked,omitempty"`
Label string `json:"label"`
Options []string `json:"options,omitempty"`
Options []Option `json:"options,omitempty"`
id string
own string // the label of this control alone: <label for>, a wrapping <label>, or a radio button's text after it
row string // what the control's table row (or <dt>) calls it
}
// Option is a choice of a select (Value and Text) or a button of a radio group (Value and Label).
type Option struct {
Value string `json:"value"`
Label string `json:"label,omitempty"`
Text string `json:"text,omitempty"`
}
// Form is a form of a page with its named fields.
@@ -110,6 +120,14 @@ func ParsePage(body []byte) Page {
inTextA bool
textA strings.Builder
textAIdx = -1
// Where a control stands in its row: Brother pages lay a setting out as <tr><th>name</th><td>control</td></tr>
// or <dt>name</dt><dd>control</dd>; the first cell names the row.
cellN int
cellOpen bool
cellText strings.Builder
rowFirst string
lastText string
tailIdx = -1
)
finishOption := func() {
if !inOption || selIdx < 0 {
@@ -121,7 +139,10 @@ func ParsePage(body []byte) Page {
v = squash(optText.String())
}
f := &form.Fields[selIdx]
f.Options = append(f.Options, v)
if len(f.Options) == 0 {
f.Value = v // a browser shows the first option when none is selected
}
f.Options = append(f.Options, Option{Value: v, Text: squash(optText.String())})
if optSel {
f.Value = v
}
@@ -137,6 +158,10 @@ func ParsePage(body []byte) Page {
form = Form{}
}
addField := func(f Field) {
f.row = rowFirst
if (cellOpen && cellN == 1) || f.row == "" {
f.row = lastText
}
form.Fields = append(form.Fields, f)
if inLabel && labelFor == "" {
wrapped = append(wrapped, len(form.Fields)-1)
@@ -151,6 +176,17 @@ func ParsePage(body []byte) Page {
case inTextA:
textA.WriteString(t)
}
if !inTitle && !inOption && !inTextA {
if q := squash(t); q != "" {
lastText = q
if tailIdx >= 0 && form.Fields[tailIdx].own == "" {
form.Fields[tailIdx].own = q
}
}
if cellOpen && cellN == 1 {
cellText.WriteString(t)
}
}
if link != nil {
link.Text += t
}
@@ -189,6 +225,7 @@ func ParsePage(body []byte) Page {
if end < 0 {
break
}
tailIdx = -1
raw := s[i+1 : i+end]
i += end + 1
closing := strings.HasPrefix(raw, "/")
@@ -214,6 +251,25 @@ func ParsePage(body []byte) Page {
switch name {
case "title":
inTitle = !closing
case "tr":
cellN, cellOpen, rowFirst, lastText = 0, false, "", ""
cellText.Reset()
case "td", "th", "dt", "dd":
if cellOpen && cellN == 1 {
rowFirst = squash(cellText.String())
}
cellOpen = false
if name == "dt" && !closing {
cellN, rowFirst, lastText = 0, "", ""
}
if !closing {
cellN++
if name == "dd" && cellN < 2 {
cellN = 2
}
cellOpen = true
cellText.Reset()
}
case "a":
if closing {
if link != nil {
@@ -240,7 +296,7 @@ func ParsePage(body []byte) Page {
labelIDs[labelFor] = t
}
for _, k := range wrapped {
form.Fields[k].Label = t
form.Fields[k].own = t
}
inLabel, labelFor, wrapped = false, "", nil
label.Reset()
@@ -263,7 +319,14 @@ func ParsePage(body []byte) Page {
if typ == "password" {
f.Value = ""
}
if typ == "checkbox" || typ == "radio" {
_, on := a["checked"]
f.Checked = &on
}
addField(f)
if typ == "radio" {
tailIdx = len(form.Fields) - 1
}
case "select":
finishOption()
selIdx = -1
@@ -298,12 +361,33 @@ func ParsePage(body []byte) Page {
closeForm()
p.Title = squash(title.String())
for fi := range p.Forms {
var fields []Field
groups := map[string]int{}
for k := range p.Forms[fi].Fields {
f := &p.Forms[fi].Fields[k]
if f.Label == "" && f.id != "" {
f.Label = labelIDs[f.id]
f := p.Forms[fi].Fields[k]
if f.own == "" && f.id != "" {
f.own = labelIDs[f.id]
}
if f.Type != "radio" {
f.Label = f.own
if f.Label == "" && f.Type != "hidden" {
f.Label = f.row
}
fields = append(fields, f)
continue
}
g, ok := groups[f.Name]
if !ok {
g = len(fields)
groups[f.Name] = g
fields = append(fields, Field{Name: f.Name, Type: "radio", Label: f.row})
}
fields[g].Options = append(fields[g].Options, Option{Value: f.Value, Label: f.own})
if f.Checked != nil && *f.Checked {
fields[g].Value = f.Value
}
}
p.Forms[fi].Fields = fields
}
return p
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func fieldNamed(t *testing.T, p Page, name string) Field {
t.Helper()
for _, f := range p.Forms {
for _, fl := range f.Fields {
if fl.Name == name {
return fl
}
}
}
t.Fatalf("no field %s in %+v", name, p.Forms)
return Field{}
}
// 1530: a radio group is ONE field that answers the checked button's value, and every button is an option.
func TestARadioGroupIsOneFieldAnsweringTheCheckedButton(t *testing.T) {
p := ParsePage([]byte(`<form><table><tr><th>Mode</th><td>
<input type="radio" id="a" name="R" value="1"/><label for="a">Enabled</label>
<input type="radio" id="b" name="R" value="0" checked="checked"/><label for="b">Disabled</label></td></tr></table></form>`))
if n := len(p.Forms[0].Fields); n != 1 {
t.Fatalf("%d fields, want the radio group as one: %+v", n, p.Forms[0].Fields)
}
f := fieldNamed(t, p, "R")
if f.Type != "radio" || f.Value != "0" || f.Label != "Mode" {
t.Errorf("group = %+v", f)
}
if len(f.Options) != 2 || f.Options[0] != (Option{Value: "1", Label: "Enabled"}) || f.Options[1] != (Option{Value: "0", Label: "Disabled"}) {
t.Errorf("options = %+v", f.Options)
}
}
// 1530: a checkbox answers whether it is checked, both ways.
func TestACheckboxAnswersCheckedOrNot(t *testing.T) {
p := ParsePage([]byte(`<form><input type="checkbox" name="on" value="1" checked/><input type="checkbox" name="off" value="1"/></form>`))
on, off := fieldNamed(t, p, "on"), fieldNamed(t, p, "off")
if on.Checked == nil || !*on.Checked || off.Checked == nil || *off.Checked {
t.Errorf("on = %+v, off = %+v", on, off)
}
raw, _ := json.Marshal(off)
if !strings.Contains(string(raw), `"checked":false`) {
t.Errorf("an unchecked box says so in JSON: %s", raw)
}
}
// 1530: a select answers the selected option's value, else the first, with its options as value and text.
func TestASelectAnswersTheSelectedOptionElseTheFirst(t *testing.T) {
p := ParsePage([]byte(`<form><select name="s"><option value="0">English</option><option value="3" selected>Deutsch</option></select>
<select name="d"><option value="0">English</option><option value="3">Deutsch</option></select></form>`))
s, d := fieldNamed(t, p, "s"), fieldNamed(t, p, "d")
if s.Value != "3" || d.Value != "0" {
t.Errorf("selected %q, default %q", s.Value, d.Value)
}
if len(s.Options) != 2 || s.Options[1] != (Option{Value: "3", Text: "Deutsch"}) {
t.Errorf("options = %+v", s.Options)
}
}
// 1530: a field with no <label for> is labelled by its table row's first cell, or its <dt>.
func TestAFieldIsLabelledByItsTableRow(t *testing.T) {
p := ParsePage([]byte(`<form><table><tr><td>Sleep time</td><td><input type="text" name="B1"/></td></tr></table>
<dl><dt>Volume</dt><dd><input type="text" name="B2"/></dd></dl>
<table><tr><td><label for="c">Own</label></td><td><input id="c" type="text" name="B3"/></td></tr></table></form>`))
for name, want := range map[string]string{"B1": "Sleep time", "B2": "Volume", "B3": "Own"} {
if got := fieldNamed(t, p, name).Label; got != want {
t.Errorf("%s label = %q, want %q", name, got, want)
}
}
}
// 1530: the reconstructed scan-from-PC page answers its radio group whole.
func TestTheScanFromPCPageAnswersItsRadioGroup(t *testing.T) {
p := ParsePage([]byte(adminFixture(t, "reconstructed-scanfrompc.html")))
f := fieldNamed(t, p, "B117d")
if f.Value != "1" || f.Label != "Scan from PC" || len(f.Options) != 2 {
t.Errorf("B117d = %+v", f)
}
}
+28 -3
View File
@@ -49,6 +49,20 @@ func strArg(args map[string]any, name string) (string, error) {
return s, nil
}
// boolArgs reads the two flags printer.settings takes: links and hidden.
func boolArgs(args map[string]any) (links, hidden bool, err error) {
for name, dst := range map[string]*bool{"links": &links, "hidden": &hidden} {
if v, ok := args[name]; ok {
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
*dst = b
}
}
return links, hidden, nil
}
// askFrom reads the arguments an ask takes out of a tool's.
func askFrom(args map[string]any) (Ask, error) {
var a Ask
@@ -159,13 +173,20 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
Description: "Read one page of the printer's admin site, read-only: the module's device process logs in with the " +
"secret admin-password (the operator's; it trusts the printer's pinned certificate alone), gets the page and logs " +
"out; this tool asks it and waits up to a minute for its answer. Answers the " +
"page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " +
"page's title and its forms with their fields, each answering its current value: a text, textarea or hidden " +
"field its value; a checkbox its value and checked (true or false); a select its selected option's value (the " +
"first when none is marked) and options (value, text); a radio group ONE field whose value is the checked button's " +
"and whose options are the buttons (value, label). A field's label is its <label for>, else the first cell of its " +
"table row or its <dt>. The page's links (href and text) are answered only with links: true (they repeat the " +
"printer's whole menu), hidden fields (CSRF token, page id) only with hidden: true. A " +
"password field's value is never given. It sends nothing but the login and the logout, and writes no " +
"setting of the printer.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /home/status.html)"},
"page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /home/status.html)"},
"links": map[string]any{"type": "boolean", "description": "also answer the page's links (default false)"},
"hidden": map[string]any{"type": "boolean", "description": "also answer the hidden fields (default false)"},
},
},
Run: func(args map[string]any) (any, error) {
@@ -183,7 +204,11 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return s.PrinterSettings(ctx, page)
links, hidden, err := boolArgs(args)
if err != nil {
return nil, err
}
return s.PrinterSettings(ctx, page, links, hidden)
},
},
{
+4 -2
View File
@@ -60,6 +60,8 @@ type Ask struct {
At time.Time `json:"asked-at"`
Page string `json:"page,omitempty"` // settings
Links bool `json:"links,omitempty"` // settings: the page's links are answered too
Hidden bool `json:"hidden,omitempty"` // settings: the hidden fields are answered too
Object string `json:"object,omitempty"` // document, print: a scan's key
Upload string `json:"upload,omitempty"` // upload, print: an upload's id
Part int `json:"part,omitempty"` // document, upload: counted from 1
@@ -149,7 +151,7 @@ func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[st
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) {
func (s *Service) PrinterSettings(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
@@ -159,7 +161,7 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]
if err := s.Settings.MissingPrinter(); err != nil {
return nil, err
}
return s.call(ctx, Ask{Op: OpSettings, Page: page}, s.AnswerWait)
return s.call(ctx, Ask{Op: OpSettings, Page: page, Links: links, Hidden: hidden}, s.AnswerWait)
}
func validPage(page string) bool {
+46 -5
View File
@@ -38,8 +38,8 @@ func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) {
if a.Title != "Synthetic settings" {
t.Errorf("title = %q", a.Title)
}
if len(a.Links) != 2 || a.Links[1] != (Link{Href: "/general/status.html", Text: "Status"}) {
t.Errorf("links = %v", a.Links)
if len(a.Links) != 0 {
t.Errorf("links in the default answer: %v", a.Links)
}
if len(a.Forms) != 1 || a.Forms[0].Action != "/general/other.html" || a.Forms[0].Method != "post" {
t.Fatalf("forms = %+v", a.Forms)
@@ -49,11 +49,10 @@ func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) {
"node": {Name: "node", Type: "text", Value: "BRN-SYNTH", Label: "Name"},
"pw": {Name: "pw", Type: "password", Value: "", Label: "Secret"},
"note": {Name: "note", Type: "textarea", Value: "hello & bye"},
"token": {Name: "token", Type: "hidden", Value: "t1"},
}
for _, fl := range a.Forms[0].Fields {
if fl.Name == "mode" {
if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || strings.Join(fl.Options, ",") != "a,m" {
if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || len(fl.Options) != 2 || fl.Options[0] != (Option{Value: "a", Text: "Auto"}) || fl.Options[1] != (Option{Value: "m", Text: "Manual"}) {
t.Errorf("mode = %+v", fl)
}
continue
@@ -155,7 +154,7 @@ func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) {
if _, err := r.run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") {
t.Errorf("page %q: error = %v", p, err)
}
if _, err := r.worker.read(t.Context(), p); err == nil || !strings.Contains(err.Error(), "not a page") {
if _, err := r.worker.read(t.Context(), p, false, false); err == nil || !strings.Contains(err.Error(), "not a page") {
t.Errorf("the device process, page %q: error = %v", p, err)
}
}
@@ -239,3 +238,45 @@ func TestPrinterSettingsDefaultPageAndHomeStatusAreReadAfterTheLogin(t *testing.
t.Errorf("DefaultPage = %s", DefaultPage)
}
}
// 1530: the printer's menu links are left out of the answer unless links:true is asked, and hidden fields
// (the CSRF token) unless hidden:true is asked; a password field's value is never given.
func TestPrinterSettingsLeavesOutLinksAndHiddenFieldsUnlessAsked(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
f.pages["/scan/scanfrompc.html"] = adminFixture(t, "reconstructed-scanfrompc.html")
_, run := printerTools(t, f, "pw")
read := func(args map[string]any) map[string]any {
t.Helper()
got, err := run(args)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(got)
var m map[string]any
json.Unmarshal(raw, &m)
return m
}
names := func(m map[string]any) string {
var n []string
for _, fm := range m["forms"].([]any) {
for _, fl := range fm.(map[string]any)["fields"].([]any) {
n = append(n, fl.(map[string]any)["name"].(string))
}
}
return strings.Join(n, ",")
}
plain := read(map[string]any{"page": "/scan/scanfrompc.html"})
if _, has := plain["links"]; has {
t.Errorf("links in the default answer: %v", plain["links"])
}
if names(plain) != "B117d" {
t.Errorf("default fields = %s, want B117d alone", names(plain))
}
more := read(map[string]any{"page": "/scan/scanfrompc.html", "links": true, "hidden": true})
if l, _ := more["links"].([]any); len(l) != 3 {
t.Errorf("links:true gave %v", more["links"])
}
if names(more) != "pageid,CSRFToken,postif_registration_reject,B117d" {
t.Errorf("hidden:true fields = %s", names(more))
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Brother MFC-L8390CDW series</title></head><body>
<!-- RECONSTRUCTED, not captured: built from what printer.settings answered for /scan/scanfrompc.html on 2026-10-21
(the form actions, the hidden fields without their token values, the radio group B117d with the labels Enabled and Disabled
and the values 1 and 0). Which button is checked, the table layout and the row heading are assumptions. -->
<a href="/home/status.html">Status</a><a href="/scan/scanfrompc.html">Scan from PC</a><a href="/home/logout.html">Logout</a>
<form method="post" action="/scan/scanfrompc.html"><input type="hidden" name="pageid" value="247"/><input type="hidden" name="CSRFToken" value="TOKEN"/><input type="hidden" name="postif_registration_reject" value="1"/>
<table><tr><th>Scan from PC</th><td><input type="radio" id="B117d_1" name="B117d" value="1" checked="checked"/><label for="B117d_1">Enabled</label>
<input type="radio" id="B117d_0" name="B117d" value="0"/><label for="B117d_0">Disabled</label></td></tr></table>
<input type="submit" value="Submit"/></form></body></html>